Compare commits
37 Commits
feature/la
...
54ef9121c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 54ef9121c7 | |||
| 7152482687 | |||
| 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 |
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">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Spritesheet Generator - Create Game Spritesheets Online</title>
|
||||
<meta name="title" content="Spritesheet Generator - Create Game Spritesheets Online">
|
||||
<title>Spritesheet generator - Create Game Spritesheets Online</title>
|
||||
<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="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="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<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:image" content="/og-image.png">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<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:image" content="/og-image.png">
|
||||
|
||||
|
||||
365
package-lock.json
generated
365
package-lock.json
generated
@@ -9,18 +9,22 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.1",
|
||||
"buffer": "^6.0.3",
|
||||
"gif.js": "^0.2.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^15.0.7",
|
||||
"pinia": "^3.0.1",
|
||||
"pocketbase": "^0.26.2",
|
||||
"vue": "^3.5.13"
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/node": "^22.19.1",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
@@ -1933,6 +1937,19 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz",
|
||||
@@ -2084,39 +2101,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
|
||||
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
|
||||
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/shared": "3.5.24",
|
||||
"@vue/shared": "3.5.25",
|
||||
"entities": "^4.5.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
|
||||
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/compiler-core": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
|
||||
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
|
||||
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24",
|
||||
"@vue/compiler-core": "3.5.25",
|
||||
"@vue/compiler-dom": "3.5.25",
|
||||
"@vue/compiler-ssr": "3.5.25",
|
||||
"@vue/shared": "3.5.25",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -2124,13 +2141,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
|
||||
"integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
|
||||
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/compiler-dom": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-vue2": {
|
||||
@@ -2240,53 +2257,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
|
||||
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
|
||||
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/shared": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
|
||||
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
|
||||
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/reactivity": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
|
||||
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/runtime-core": "3.5.24",
|
||||
"@vue/shared": "3.5.24",
|
||||
"@vue/reactivity": "3.5.25",
|
||||
"@vue/runtime-core": "3.5.25",
|
||||
"@vue/shared": "3.5.25",
|
||||
"csstype": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
|
||||
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
|
||||
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/compiler-ssr": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.24"
|
||||
"vue": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
|
||||
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
|
||||
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/tsconfig": {
|
||||
@@ -2328,6 +2345,15 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
|
||||
@@ -2373,10 +2399,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.30",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz",
|
||||
"integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==",
|
||||
"version": "2.8.31",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
|
||||
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2449,6 +2495,30 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-builder": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz",
|
||||
@@ -2473,9 +2543,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001756",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz",
|
||||
"integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==",
|
||||
"version": "1.0.30001757",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2582,6 +2652,19 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2666,9 +2749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.259",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz",
|
||||
"integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==",
|
||||
"version": "1.5.260",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz",
|
||||
"integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -2758,6 +2841,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
@@ -2791,6 +2887,18 @@
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||
@@ -2902,6 +3010,21 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -2938,6 +3061,26 @@
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
@@ -2973,6 +3116,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3124,6 +3276,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -3185,6 +3350,15 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kolorist": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
||||
@@ -3814,6 +3988,20 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
@@ -4330,6 +4518,19 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -4431,6 +4632,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
@@ -4440,6 +4647,15 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-final-newline": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
|
||||
@@ -4885,16 +5101,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
|
||||
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
||||
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.24",
|
||||
"@vue/compiler-sfc": "3.5.24",
|
||||
"@vue/runtime-dom": "3.5.24",
|
||||
"@vue/server-renderer": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/compiler-dom": "3.5.25",
|
||||
"@vue/compiler-sfc": "3.5.25",
|
||||
"@vue/runtime-dom": "3.5.25",
|
||||
"@vue/server-renderer": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
@@ -4905,6 +5121,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
|
||||
"integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router/node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
|
||||
|
||||
@@ -13,18 +13,22 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.1",
|
||||
"buffer": "^6.0.3",
|
||||
"gif.js": "^0.2.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^15.0.7",
|
||||
"pinia": "^3.0.1",
|
||||
"pocketbase": "^0.26.2",
|
||||
"vue": "^3.5.13"
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/node": "^22.19.1",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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
|
||||
|
||||
BIN
public/blog/1.png
Normal file
BIN
public/blog/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 758 KiB |
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
|
||||
34
public/sitemap.xml
Normal file
34
public/sitemap.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?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">
|
||||
|
||||
<url>
|
||||
<loc>https://spritesheetgenerator.online/</loc>
|
||||
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://spritesheetgenerator.online/about</loc>
|
||||
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://spritesheetgenerator.online/contact</loc>
|
||||
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://spritesheetgenerator.online/privacy-policy</loc>
|
||||
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://spritesheetgenerator.online/blog</loc>
|
||||
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://spritesheetgenerator.online/blog/welcome</loc>
|
||||
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
|
||||
</url>
|
||||
|
||||
|
||||
</urlset>
|
||||
316
src/App.vue
316
src/App.vue
@@ -1,160 +1,62 @@
|
||||
<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="max-w-7xl mx-auto">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-4 sm:mb-8 gap-2">
|
||||
<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>
|
||||
<dark-mode-toggle />
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-4 mb-4 sm:mb-8">
|
||||
<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="!visibleLayers.some(l => l.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 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) && $route.name === 'home' }">
|
||||
<div class="flex flex-col flex-1" :class="{ 'lg:overflow-hidden': layers.some(l => l.sprites.length) && $route.name === 'home' }">
|
||||
<header class="mb-6 sm:mb-5">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-6 mb-8">
|
||||
<div class="text-center sm:text-left">
|
||||
<router-link to="/" class="block group">
|
||||
<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 group-hover:opacity-80 transition-opacity">Spritesheet generator</h1>
|
||||
</router-link>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex flex-col items-center sm:items-end gap-3">
|
||||
<nav class="flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="https://gitea.adhd.sh/root/spritesheet-generator" target="_blank" class="btn btn-secondary hover:shadow-md" data-rybbit-event="source-link">
|
||||
<i class="fab fa-github"></i>
|
||||
<span class="font-medium">Source</span>
|
||||
</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 class="flex gap-4 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
<router-link to="/" class="hover:text-gray-900 dark:hover:text-white transition-colors">Home</router-link>
|
||||
<router-link to="/blog" class="hover:text-gray-900 dark:hover:text-white transition-colors">Blog</router-link>
|
||||
<router-link to="/about" class="hover:text-gray-900 dark:hover:text-white transition-colors">About Us</router-link>
|
||||
<router-link to="/contact" class="hover:text-gray-900 dark:hover:text-white transition-colors">Contact</router-link>
|
||||
<router-link to="/privacy-policy" class="hover:text-gray-900 dark:hover:text-white transition-colors">Privacy Policy</router-link>
|
||||
<a href="/sitemap.xml" target="_blank" class="hover:text-gray-900 dark:hover:text-white transition-colors">Sitemap</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="visibleLayers.some(l => l.sprites.length)" class="mt-8">
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-gray-700 dark:text-gray-200 font-medium">Layers</span>
|
||||
<button @click="addLayer()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100 rounded">Add</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="layer in layers" :key="layer.id" class="flex items-center gap-2 px-2 py-1 rounded border border-gray-200 dark:border-gray-600" :class="{ 'ring-2 ring-blue-500': layer.id === activeLayerId }">
|
||||
<button @click="activeLayerId = layer.id" class="px-2 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">{{ layer.name }}</button>
|
||||
<label class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300"> <input type="checkbox" v-model="layer.visible" /> Visible </label>
|
||||
<button @click="moveLayer(layer.id, 'up')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">↑</button>
|
||||
<button @click="moveLayer(layer.id, 'down')" class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">↓</button>
|
||||
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-200 rounded">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-3 sm:gap-6 mb-6 sm:mb-8">
|
||||
<div class="flex items-center space-x-1">
|
||||
<label for="columns" class="text-gray-700 dark:text-gray-200 font-medium">Columns:</label>
|
||||
<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>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-gray-700 dark:text-gray-200 font-medium">Cell size:</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">{{ cellSize.width }} × {{ cellSize.height }}px</span>
|
||||
</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>
|
||||
<span>Export as JSON</span>
|
||||
</button>
|
||||
|
||||
<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">
|
||||
<i class="fas fa-film"></i>
|
||||
<span>Download as GIF</span>
|
||||
</button>
|
||||
|
||||
<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">
|
||||
<i class="fas fa-file-archive"></i>
|
||||
<span>Download as ZIP</span>
|
||||
</button>
|
||||
|
||||
<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">
|
||||
<i class="fas fa-play"></i>
|
||||
<span>Preview animation</span>
|
||||
</button>
|
||||
</div>
|
||||
<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"
|
||||
@add-sprite-with-resize="addSpriteWithResize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<Modal :is-open="isPreviewModalOpen" @close="closePreviewModal" title="Animation preview">
|
||||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" />
|
||||
</Modal>
|
||||
|
||||
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
|
||||
<FeedbackModal :is-open="isFeedbackModalOpen" @close="closeFeedbackModal" />
|
||||
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
|
||||
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
|
||||
|
||||
<!-- One-time feedback popup -->
|
||||
<div v-if="showFeedbackPopup" class="fixed inset-0 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md mx-4 shadow-xl border border-gray-600">
|
||||
<div v-if="showFeedbackPopup" class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||
<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-4xl mb-4">💬</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">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>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<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(true)" class="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-lg transition-colors">Share feedback</button>
|
||||
<div class="mb-4 text-4xl">💬</div>
|
||||
<h3 class="mb-3 text-lg font-semibold text-gray-900 dark:text-white">Help us improve!</h3>
|
||||
<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 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 cursor-pointer">Maybe later</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>
|
||||
@@ -163,92 +65,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, toRef, computed } from 'vue';
|
||||
import FileUploader from './components/FileUploader.vue';
|
||||
import SpriteCanvas from './components/SpriteCanvas.vue';
|
||||
import Modal from './components/utilities/Modal.vue';
|
||||
import SpritePreview from './components/SpritePreview.vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { RouterView, RouterLink } from 'vue-router';
|
||||
import HelpModal from './components/HelpModal.vue';
|
||||
import FeedbackModal from './components/FeedbackModal.vue';
|
||||
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
|
||||
import GifFpsModal from './components/GifFpsModal.vue';
|
||||
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
|
||||
import { useExportLayers } from './composables/useExportLayers';
|
||||
import { useLayers } from './composables/useLayers';
|
||||
import { getMaxDimensionsAcrossLayers } from './composables/useLayers';
|
||||
import { useSettingsStore } from './stores/useSettingsStore';
|
||||
import { calculateNegativeSpacing } from './composables/useNegativeSpacing';
|
||||
import type { SpriteFile } from './types/sprites';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteCell, removeSprite, replaceSprite, addSprite, addSpriteWithResize, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
|
||||
const { layers } = useLayers();
|
||||
|
||||
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'), activeLayerId, toRef(settingsStore, 'backgroundColor'));
|
||||
|
||||
const cellSize = computed(() => {
|
||||
if (!layers.value.length) return { width: 0, height: 0 };
|
||||
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 isPreviewModalOpen = ref(false);
|
||||
const isHelpModalOpen = ref(false);
|
||||
const isFeedbackModalOpen = ref(false);
|
||||
const isSpritesheetSplitterOpen = ref(false);
|
||||
const isGifFpsModalOpen = ref(false);
|
||||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||||
const spritesheetImageUrl = ref('');
|
||||
const spritesheetImageFile = ref<File | null>(null);
|
||||
const showFeedbackPopup = ref(false);
|
||||
|
||||
const handleSpritesUpload = async (files: File[]) => {
|
||||
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
||||
|
||||
if (jsonFile) {
|
||||
try {
|
||||
await importSpritesheetJSON(jsonFile);
|
||||
} catch (error) {
|
||||
console.error('Error importing JSON:', error);
|
||||
alert('Failed to import JSON file. Please check the file format.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 1 && files[0].type.startsWith('image/')) {
|
||||
const file = files[0];
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
processImageFiles([file]);
|
||||
};
|
||||
img.src = url;
|
||||
return;
|
||||
}
|
||||
|
||||
processImageFiles(files);
|
||||
};
|
||||
|
||||
const openPreviewModal = () => {
|
||||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||
alert('Please upload or import sprites to preview an animation.');
|
||||
return;
|
||||
}
|
||||
isPreviewModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closePreviewModal = () => {
|
||||
isPreviewModalOpen.value = false;
|
||||
};
|
||||
|
||||
const openHelpModal = () => {
|
||||
isHelpModalOpen.value = true;
|
||||
};
|
||||
@@ -265,49 +94,6 @@
|
||||
isFeedbackModalOpen.value = false;
|
||||
};
|
||||
|
||||
const closeSpritesheetSplitter = () => {
|
||||
isSpritesheetSplitterOpen.value = false;
|
||||
if (spritesheetImageUrl.value) {
|
||||
URL.revokeObjectURL(spritesheetImageUrl.value);
|
||||
spritesheetImageUrl.value = '';
|
||||
}
|
||||
spritesheetImageFile.value = null;
|
||||
};
|
||||
|
||||
const openGifFpsModal = () => {
|
||||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
isGifFpsModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeGifFpsModal = () => {
|
||||
isGifFpsModalOpen.value = false;
|
||||
};
|
||||
|
||||
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
||||
processImageFiles(spriteFiles.map(s => s.file));
|
||||
};
|
||||
|
||||
const openJSONImportDialog = () => {
|
||||
jsonFileInput.value?.click();
|
||||
};
|
||||
|
||||
const handleJSONFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
const jsonFile = input.files[0];
|
||||
try {
|
||||
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 = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const hasShownFeedbackPopup = localStorage.getItem('hasShownFeedbackPopup');
|
||||
if (!hasShownFeedbackPopup) {
|
||||
|
||||
BIN
src/assets/demo.mp4
Normal file
BIN
src/assets/demo.mp4
Normal file
Binary file not shown.
@@ -37,3 +37,142 @@ html.dark {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Custom prose styles for blog content */
|
||||
.prose {
|
||||
@apply text-gray-700 dark:text-gray-300 leading-7;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
@apply text-4xl font-bold text-gray-900 dark:text-white mb-4 mt-0;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
@apply text-3xl font-bold text-gray-900 dark:text-white mt-8 mb-4;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
@apply text-2xl font-bold text-gray-900 dark:text-white mt-6 mb-3;
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
@apply text-xl font-bold text-gray-900 dark:text-white mt-4 mb-2;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
@apply text-gray-700 dark:text-gray-300 mb-4 leading-7;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
@apply text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300;
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
@apply text-gray-900 dark:text-white font-semibold;
|
||||
}
|
||||
|
||||
.prose em {
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
@apply text-sm font-mono bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-1 py-0.5 rounded;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
@apply bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 p-4 rounded-lg overflow-x-auto mb-4;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
@apply list-disc pl-6 mb-4 text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
@apply list-decimal pl-6 mb-4 text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
@apply mb-2 leading-7;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
@apply border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-4;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
@apply rounded-lg shadow-md my-6;
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
@apply border-gray-300 dark:border-gray-700 my-8;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
@apply border-collapse w-full my-6;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
@apply bg-gray-100 dark:bg-gray-800 p-2 text-left font-semibold border border-gray-300 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.prose td {
|
||||
@apply border border-gray-300 dark:border-gray-700 p-2;
|
||||
}
|
||||
}
|
||||
|
||||
23
src/blog/welcome.md
Normal file
23
src/blog/welcome.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: 'Welcome to the Spritesheet generator blog'
|
||||
date: '2025-11-26'
|
||||
description: 'This is the first post on our new blog. Learn about how we built this tool.'
|
||||
image: '/blog/1.png'
|
||||
---
|
||||
|
||||
## Welcome!
|
||||
|
||||
We are excited to launch our new blog. Here we will share updates, tutorials, and tips on how to get the most out of the Sprite Sheet Generator.
|
||||
|
||||
### What is this tool?
|
||||
|
||||
This tool allows you to easily combine multiple images into a single sprite sheet. It's perfect for game developers and web designers.
|
||||
|
||||
### Features
|
||||
|
||||
- Drag and drop interface
|
||||
- Customizable grid size
|
||||
- Export to PNG and JSON
|
||||
- Dark mode support
|
||||
|
||||
Stay tuned for more updates!
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<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="{
|
||||
'border-blue-300 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/30': 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-blue-400 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/40 scale-[1.02]': 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"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@@ -12,19 +12,35 @@
|
||||
@click="openFileDialog"
|
||||
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" />
|
||||
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<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="relative z-10">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,60 +1,120 @@
|
||||
<template>
|
||||
<div class="p-2 bg-cyan-600 rounded w-full my-4">
|
||||
<p>Developer's tip: Right click a sprite to open the context menu and add, replace or remove sprites.</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-0">
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 w-full sm:w-auto">
|
||||
<div class="flex items-center">
|
||||
<input id="pixel-perfect" type="checkbox" v-model="settingsStore.pixelPerfect" class="mr-2 w-4 h-4" @change="requestDraw" />
|
||||
<label for="pixel-perfect" class="dark:text-gray-200 text-sm sm:text-base">Pixel perfect rendering</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input id="allow-cell-swap" type="checkbox" v-model="allowCellSwap" class="mr-2 w-4 h-4" />
|
||||
<label for="allow-cell-swap" class="dark:text-gray-200 text-sm sm:text-base">Allow moving between cells</label>
|
||||
</div>
|
||||
<!-- Add new checkbox for showing all sprites -->
|
||||
<div class="flex items-center">
|
||||
<input id="show-all-sprites" type="checkbox" v-model="showAllSprites" class="mr-2" />
|
||||
<label for="show-all-sprites" class="dark:text-gray-200">Compare sprites</label>
|
||||
</div>
|
||||
<!-- Negative spacing control -->
|
||||
<div class="flex items-center">
|
||||
<input id="negative-spacing" type="checkbox" v-model="settingsStore.negativeSpacingEnabled" class="mr-2 w-4 h-4" />
|
||||
<label for="negative-spacing" class="dark:text-gray-200 text-sm sm:text-base">Negative spacing</label>
|
||||
</div>
|
||||
<!-- Background color picker -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="bg-color" class="dark:text-gray-200 text-sm sm:text-base">Background:</label>
|
||||
<select id="bg-color" v-model="settingsStore.backgroundColor" class="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 dark:text-gray-200 text-sm">
|
||||
<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>
|
||||
<input v-if="settingsStore.backgroundColor === 'custom'" type="color" v-model="customColor" @input="settingsStore.setBackgroundColor(customColor)" class="w-8 h-8 border border-gray-300 dark:border-gray-600 rounded cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<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' }">
|
||||
<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">
|
||||
<i class="fas fa-plus text-blue-600 dark:text-blue-400"></i>
|
||||
<span>Add Sprite</span>
|
||||
</button>
|
||||
<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">
|
||||
<i class="fas fa-exchange-alt text-purple-600 dark:text-purple-400"></i>
|
||||
<span>Replace Sprite</span>
|
||||
</button>
|
||||
<div v-if="contextMenuSpriteId" class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
|
||||
<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">
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>Remove Sprite</span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="space-y-6 w-full max-w-full overflow-hidden">
|
||||
<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">
|
||||
<i class="fas fa-info-circle text-cyan-600 dark:text-cyan-400 mt-0.5 flex-shrink-0"></i>
|
||||
<p class="text-sm text-cyan-800 dark:text-cyan-200"><span class="font-semibold">Tip:</span> Right-click any sprite to open the context menu for quick actions: add, replace, or remove sprites.</p>
|
||||
</div>
|
||||
|
||||
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-auto">
|
||||
<!-- Zoom controls - visible on all screen sizes and positioned outside cells -->
|
||||
<div class="relative flex space-x-2 bg-white/90 dark:bg-gray-800/90 p-2 rounded-lg shadow-md z-20">
|
||||
<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">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<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">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
<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">
|
||||
<i class="fas fa-expand"></i>
|
||||
</button>
|
||||
</div>
|
||||
<section class="w-full bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-1 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<!-- Toggles 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="Pixel Perfect">
|
||||
<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" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Pixel Perfect</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="Cell Swapping">
|
||||
<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" />
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Swap</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="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' }">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
<div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||
|
||||
<!-- 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"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@@ -67,20 +127,102 @@
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="handleDrop"
|
||||
class="w-full transition-all"
|
||||
:class="{ 'ring-4 ring-blue-500 ring-opacity-50': isDragOver }"
|
||||
:style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}"
|
||||
></canvas>
|
||||
|
||||
<!-- Offset labels in corners -->
|
||||
<div v-if="canvasRef" class="absolute inset-0 pointer-events-none">
|
||||
>
|
||||
<!-- Grid cells -->
|
||||
<div
|
||||
v-for="position in spritePositions"
|
||||
:key="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"
|
||||
v-for="cellIndex in totalCells"
|
||||
:key="`cell-${cellIndex - 1}`"
|
||||
class="absolute"
|
||||
:style="{
|
||||
left: `calc(${(position.cellX / canvasRef.width) * 100}% + ${(position.maxWidth / canvasRef.width) * 100}% - 2px)`,
|
||||
top: `calc(${(position.cellY / canvasRef.height) * 100}% + ${(position.maxHeight / canvasRef.height) * 100}% - 2px)`,
|
||||
left: `${getCellPosition(cellIndex - 1).x}px`,
|
||||
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%)',
|
||||
}"
|
||||
>
|
||||
@@ -90,32 +232,14 @@
|
||||
</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" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted, toRef, computed } from 'vue';
|
||||
import { ref, onMounted, watch, onUnmounted, toRef, computed, nextTick } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Sprite } from '@/types/sprites';
|
||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||
import { useZoom } from '@/composables/useZoom';
|
||||
import { useDragSprite } from '@/composables/useDragSprite';
|
||||
import { useFileDrop } from '@/composables/useFileDrop';
|
||||
@@ -140,21 +264,7 @@
|
||||
// Get settings from store
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
// rAF-based draw scheduler to coalesce multiple draw requests into a single frame
|
||||
// Define before usage to avoid TDZ issues when passing into composables during setup
|
||||
let rafId: number | null = null;
|
||||
function requestDraw() {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
drawCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize composables
|
||||
const canvas2D = useCanvas2D(canvasRef);
|
||||
const gridContainerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const {
|
||||
zoom,
|
||||
@@ -170,6 +280,18 @@
|
||||
|
||||
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 {
|
||||
isDragging,
|
||||
activeSpriteId,
|
||||
@@ -185,17 +307,23 @@
|
||||
calculateMaxDimensions,
|
||||
} = useDragSprite({
|
||||
sprites: computed(() => props.layers.find(l => l.id === props.activeLayerId)?.sprites ?? []),
|
||||
layers: toRef(props, 'layers'),
|
||||
columns: toRef(props, 'columns'),
|
||||
zoom,
|
||||
allowCellSwap,
|
||||
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),
|
||||
onUpdateSpriteCell: (id, newIndex) => emit('updateSpriteCell', id, newIndex),
|
||||
onDraw: requestDraw,
|
||||
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({
|
||||
sprites: activeSprites,
|
||||
@@ -204,6 +332,7 @@
|
||||
});
|
||||
|
||||
const showAllSprites = ref(false);
|
||||
const showOffsetLabels = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenuX = ref(0);
|
||||
const contextMenuY = ref(0);
|
||||
@@ -212,8 +341,114 @@
|
||||
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) => {
|
||||
if (!canvasRef.value) return;
|
||||
if (!gridContainerRef.value) return;
|
||||
|
||||
// Hide context menu if open
|
||||
showContextMenu.value = false;
|
||||
@@ -221,25 +456,26 @@
|
||||
// Handle right-click for context menu
|
||||
if ('button' in event && (event as MouseEvent).button === 2) {
|
||||
event.preventDefault();
|
||||
const pos = canvas2D.getMousePosition(event, zoom.value);
|
||||
const pos = getMousePosition(event, zoom.value);
|
||||
if (!pos) return;
|
||||
|
||||
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
|
||||
contextMenuSpriteId.value = clickedSprite?.id || null;
|
||||
|
||||
contextMenuX.value = event.clientX;
|
||||
contextMenuY.value = event.clientY;
|
||||
|
||||
showContextMenu.value = true;
|
||||
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;
|
||||
|
||||
// Delegate to composable for actual drag handling
|
||||
dragStart(event);
|
||||
};
|
||||
|
||||
// Wrapper for drag move
|
||||
const drag = (event: MouseEvent) => {
|
||||
dragMove(event);
|
||||
};
|
||||
@@ -254,10 +490,8 @@
|
||||
|
||||
const replaceSprite = () => {
|
||||
if (contextMenuSpriteId.value && fileInput.value) {
|
||||
// Store the sprite ID separately so it persists after context menu closes
|
||||
replacingSpriteId.value = contextMenuSpriteId.value;
|
||||
fileInput.value.click();
|
||||
// Hide context menu immediately since we've stored the ID
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
}
|
||||
@@ -266,7 +500,6 @@
|
||||
const addSprite = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.click();
|
||||
// Hide context menu immediately
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
}
|
||||
@@ -281,14 +514,12 @@
|
||||
if (replacingSpriteId.value) {
|
||||
emit('replaceSprite', replacingSpriteId.value, file);
|
||||
} else {
|
||||
// Adding new sprite
|
||||
emit('addSprite', file);
|
||||
}
|
||||
} else {
|
||||
alert('Please select an image file.');
|
||||
}
|
||||
}
|
||||
// Clean up after file selection
|
||||
replacingSpriteId.value = null;
|
||||
input.value = '';
|
||||
};
|
||||
@@ -298,146 +529,26 @@
|
||||
contextMenuSpriteId.value = null;
|
||||
};
|
||||
|
||||
// Wrapper for drag leave to pass canvasRef
|
||||
const onDragLeave = (event: DragEvent) => {
|
||||
handleDragLeave(event, canvasRef.value);
|
||||
};
|
||||
|
||||
function drawCanvas() {
|
||||
if (!canvasRef.value || !canvas2D.ctx.value) return;
|
||||
|
||||
const { maxWidth, maxHeight, negativeSpacing } = calculateMaxDimensions();
|
||||
|
||||
// Set canvas size
|
||||
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));
|
||||
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) {
|
||||
const total = Math.max(...props.layers.map(l => (l.visible ? l.sprites.length : 0)));
|
||||
for (let cellIndex = 0; cellIndex < total; 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);
|
||||
|
||||
props.layers.forEach(layer => {
|
||||
if (!layer.visible) return;
|
||||
const sprite = layer.sprites[cellIndex];
|
||||
if (sprite) canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, 0.35);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Draw layers in order; active layer will be interactable
|
||||
props.layers.forEach(layer => {
|
||||
if (!layer.visible) return;
|
||||
layer.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);
|
||||
|
||||
const alpha = layer.id === props.activeLayerId ? 1 : 0.85;
|
||||
canvas2D.drawImage(sprite.img, cellX + negativeSpacing + sprite.x, cellY + negativeSpacing + sprite.y, alpha);
|
||||
});
|
||||
});
|
||||
|
||||
// Draw ghost sprite if we're dragging between cells
|
||||
if (ghostSprite.value && activeSpriteId.value) {
|
||||
const sprite = activeSprites.value.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 = () => {
|
||||
const sprites = props.layers.flatMap(l => l.sprites);
|
||||
canvas2D.attachImageListeners(sprites, handleForceRedraw, imagesWithListeners);
|
||||
handleDragLeave(event, gridContainerRef.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
canvas2D.initContext();
|
||||
requestDraw();
|
||||
|
||||
// Attach listeners for current sprites
|
||||
attachImageListeners();
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
|
||||
// Hide context menu when clicking elsewhere
|
||||
document.addEventListener('click', hideContextMenu);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
||||
document.removeEventListener('click', hideContextMenu);
|
||||
});
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
// Ensure integer positioning for crisp rendering on the active layer
|
||||
canvas2D.ensureIntegerPositions(activeSprites.value);
|
||||
canvas2D.applySmoothing();
|
||||
requestDraw();
|
||||
};
|
||||
|
||||
// Re-attach listeners and redraw whenever layers/sprites change
|
||||
// Watch for background color changes
|
||||
watch(
|
||||
() => props.layers,
|
||||
() => {
|
||||
attachImageListeners();
|
||||
requestDraw();
|
||||
},
|
||||
{ deep: true }
|
||||
() => settingsStore.backgroundColor,
|
||||
async () => {
|
||||
await nextTick();
|
||||
}
|
||||
);
|
||||
watch(() => props.columns, requestDraw);
|
||||
watch(() => settingsStore.pixelPerfect, requestDraw);
|
||||
watch(() => settingsStore.darkMode, requestDraw);
|
||||
watch(() => settingsStore.negativeSpacingEnabled, requestDraw);
|
||||
watch(() => settingsStore.backgroundColor, requestDraw);
|
||||
watch(showAllSprites, requestDraw);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,24 +1,74 @@
|
||||
<template>
|
||||
<div class="spritesheet-preview w-full">
|
||||
<!-- Main Layout: Canvas Left, Controls Right -->
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Canvas Area (Left/Main) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<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">
|
||||
<canvas
|
||||
ref="previewCanvasRef"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="stopDrag"
|
||||
class="block touch-manipulation"
|
||||
:class="{ 'cursor-move': isDraggable }"
|
||||
:style="{ transform: `scale(${zoom})`, transformOrigin: 'top left', ...(settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}) }"
|
||||
<div class="spritesheet-preview w-full h-full">
|
||||
<div class="flex flex-col lg:flex-row gap-4 h-full">
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<div
|
||||
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"
|
||||
@mousemove="drag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="stopDrag"
|
||||
>
|
||||
<div
|
||||
ref="previewContainerRef"
|
||||
class="relative touch-manipulation inline-block"
|
||||
:style="{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
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 -->
|
||||
<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,140 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Sidebar (Right) -->
|
||||
<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 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Playback</h3>
|
||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700">
|
||||
<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">
|
||||
<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">
|
||||
<span v-if="isPlaying" class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
<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-2 font-medium">
|
||||
<i class="fas fa-pause"></i>
|
||||
Pause
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
<span v-else class="flex items-center gap-2 font-medium">
|
||||
<i class="fas fa-play"></i>
|
||||
Play
|
||||
</span>
|
||||
</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 }">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 dark:text-gray-200">
|
||||
<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 @click="previousFrame" class="btn btn-secondary btn-icon rounded-lg" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
||||
<i class="fas fa-step-backward"></i>
|
||||
</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 }">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 dark:text-gray-200">
|
||||
<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 @click="nextFrame" class="btn btn-secondary btn-icon rounded-lg" :disabled="isPlaying" :class="{ 'opacity-50 cursor-not-allowed': isPlaying }">
|
||||
<i class="fas fa-step-forward"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sliders -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Controls</h3>
|
||||
<!-- Animation Settings -->
|
||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700 space-y-5">
|
||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">Animation</h3>
|
||||
|
||||
<!-- Frame Navigation -->
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-2">
|
||||
<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-xs font-mono text-gray-700 dark:text-gray-300">{{ visibleFrameNumber }}/{{ visibleFramesCount }}</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Frame</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>
|
||||
<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>
|
||||
|
||||
<!-- FPS Control -->
|
||||
<div class="space-y-1">
|
||||
<div class="space-y-2">
|
||||
<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-xs font-mono text-gray-700 dark:text-gray-300">{{ 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-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">{{ fps }}</span>
|
||||
</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" />
|
||||
</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" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Options</h3>
|
||||
<!-- View Options -->
|
||||
<div class="p-4 space-y-5">
|
||||
<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">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<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 dark:text-gray-200">Reposition</span>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Zoom</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 class="flex items-center gap-2 cursor-pointer">
|
||||
<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 dark:text-gray-200">Compare sprites</span>
|
||||
<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">Reposition mode</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 class="flex items-center gap-2 cursor-pointer">
|
||||
<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" />
|
||||
<span class="text-sm dark:text-gray-200">Pixel perfect</span>
|
||||
<div class="pl-4 border-l-2 border-gray-100 dark:border-gray-700 transition-all" :class="{ 'opacity-50 pointer-events-none': !isDraggable }">
|
||||
<label class="flex items-center justify-between cursor-pointer group">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current frame offset display -->
|
||||
<div v-if="currentFrameSprite" class="p-2 bg-gray-100 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<div class="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-mono font-semibold text-cyan-600 dark:text-cyan-400">x: {{ currentFrameSprite.x }}, y: {{ currentFrameSprite.y }}</span>
|
||||
<div v-if="currentFrameSprite" class="px-4 pb-4">
|
||||
<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-500 dark:text-gray-400 uppercase tracking-wide">Offset</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>
|
||||
|
||||
<!-- Frame Selection (when Compare sprites is enabled) -->
|
||||
<div v-if="showAllSprites" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">Frames</h3>
|
||||
<div v-if="showAllSprites" class="border-t border-gray-100 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Visible Frames</h3>
|
||||
<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="hideAllFrames" class="px-2 py-1 text-xs bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors">None</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-[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 class="rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800">
|
||||
<div class="max-h-[180px] overflow-y-auto">
|
||||
<div class="space-y-0.5 p-1">
|
||||
<div v-for="(sprite, index) in compositeFrames" :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)">
|
||||
<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)" />
|
||||
<div class="w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
<img :src="sprite.img.src" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
<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-[150px] overflow-y-auto pr-1 custom-scrollbar">
|
||||
<div class="space-y-1">
|
||||
<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-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-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.url" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
|
||||
</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>
|
||||
@@ -161,7 +232,6 @@
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
||||
import { useCanvas2D } from '@/composables/useCanvas2D';
|
||||
import { useZoom } from '@/composables/useZoom';
|
||||
import { useAnimationFrames } from '@/composables/useAnimationFrames';
|
||||
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
|
||||
@@ -174,16 +244,14 @@
|
||||
|
||||
const emit = defineEmits<{
|
||||
(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
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// Initialize composables
|
||||
const canvas2D = useCanvas2D(previewCanvasRef);
|
||||
|
||||
const {
|
||||
zoom,
|
||||
increase: increaseZoom,
|
||||
@@ -206,22 +274,29 @@
|
||||
}
|
||||
return frames;
|
||||
},
|
||||
onDraw: drawPreviewCanvas,
|
||||
onDraw: () => {}, // No longer needed for canvas drawing
|
||||
});
|
||||
|
||||
// Preview state
|
||||
const isDraggable = ref(false);
|
||||
const repositionAllLayers = ref(false);
|
||||
const showAllSprites = ref(false);
|
||||
|
||||
const compositeFrames = computed<Sprite[]>(() => {
|
||||
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);
|
||||
// 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 arr;
|
||||
return activeLayer.sprites;
|
||||
});
|
||||
|
||||
const currentFrameSprite = computed<Sprite | null>(() => {
|
||||
@@ -230,98 +305,89 @@
|
||||
return layer.sprites[currentFrameIndex.value] || null;
|
||||
});
|
||||
|
||||
// Dragging state
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||
|
||||
// Canvas drawing
|
||||
|
||||
function drawPreviewCanvas() {
|
||||
if (!previewCanvasRef.value || !canvas2D.ctx.value) return;
|
||||
// Computed cell dimensions
|
||||
const cellDimensions = computed(() => {
|
||||
const visibleLayers = getVisibleLayers();
|
||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) return;
|
||||
// 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);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
return {
|
||||
cellWidth: maxWidth + negativeSpacing,
|
||||
cellHeight: maxHeight + negativeSpacing,
|
||||
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');
|
||||
|
||||
const frameIndex = currentFrameIndex.value;
|
||||
|
||||
if (showAllSprites.value) {
|
||||
const len = maxFrames();
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (i === frameIndex || hiddenFrames.value.includes(i)) continue;
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[i];
|
||||
if (!sprite) return;
|
||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y, 0.3);
|
||||
});
|
||||
}
|
||||
// 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';
|
||||
};
|
||||
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[frameIndex];
|
||||
if (!sprite) return;
|
||||
canvas2D.drawImage(sprite.img, negativeSpacing + sprite.x, negativeSpacing + sprite.y);
|
||||
});
|
||||
|
||||
// Draw cell border
|
||||
canvas2D.strokeRect(0, 0, cellWidth, cellHeight, '#e5e7eb', 1);
|
||||
}
|
||||
// Dragging state
|
||||
const isDragging = ref(false);
|
||||
const activeSpriteId = ref<string | null>(null);
|
||||
const activeLayerId = ref<string | null>(null);
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const spritePosBeforeDrag = ref({ x: 0, y: 0 });
|
||||
const allSpritesPosBeforeDrag = ref<Map<string, { x: number; y: number }>>(new Map());
|
||||
|
||||
// Drag functionality
|
||||
const startDrag = (event: MouseEvent) => {
|
||||
if (!isDraggable.value || !previewCanvasRef.value) return;
|
||||
const startDrag = (event: MouseEvent, sprite: Sprite, layerId: string) => {
|
||||
if (!isDraggable.value || !previewContainerRef.value) return;
|
||||
|
||||
const rect = previewCanvasRef.value.getBoundingClientRect();
|
||||
const scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
||||
const rect = previewContainerRef.value.getBoundingClientRect();
|
||||
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
||||
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
||||
|
||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||
|
||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
||||
const vLayers = getVisibleLayers();
|
||||
const allSprites = vLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
if (repositionAllLayers.value) {
|
||||
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)
|
||||
if (activeSprite) {
|
||||
const spriteCanvasX = negativeSpacing + activeSprite.x;
|
||||
const spriteCanvasY = negativeSpacing + activeSprite.y;
|
||||
if (mouseX >= spriteCanvasX && mouseX <= spriteCanvasX + activeSprite.width && mouseY >= spriteCanvasY && mouseY <= spriteCanvasY + activeSprite.height) {
|
||||
isDragging.value = true;
|
||||
activeSpriteId.value = activeSprite.id;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
spritePosBeforeDrag.value = { x: activeSprite.x, y: activeSprite.y };
|
||||
}
|
||||
// Store initial positions for all sprites in this frame from all visible layers
|
||||
allSpritesPosBeforeDrag.value.clear();
|
||||
const visibleLayers = getVisibleLayers();
|
||||
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;
|
||||
activeSpriteId.value = sprite.id;
|
||||
activeLayerId.value = layerId;
|
||||
dragStartX.value = mouseX;
|
||||
dragStartY.value = mouseY;
|
||||
spritePosBeforeDrag.value = { x: sprite.x, y: sprite.y };
|
||||
}
|
||||
};
|
||||
|
||||
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 scaleX = previewCanvasRef.value.width / (rect.width / zoom.value);
|
||||
const scaleY = previewCanvasRef.value.height / (rect.height / zoom.value);
|
||||
const rect = previewContainerRef.value.getBoundingClientRect();
|
||||
const scaleX = previewContainerRef.value.offsetWidth / (rect.width / zoom.value);
|
||||
const scaleY = previewContainerRef.value.offsetHeight / (rect.height / zoom.value);
|
||||
|
||||
const mouseX = ((event.clientX - rect.left) / zoom.value) * scaleX;
|
||||
const mouseY = ((event.clientY - rect.top) / zoom.value) * scaleY;
|
||||
@@ -329,34 +395,52 @@
|
||||
const deltaX = Math.round(mouseX - dragStartX.value);
|
||||
const deltaY = Math.round(mouseY - dragStartY.value);
|
||||
|
||||
const activeSprite = props.layers.find(l => l.id === props.activeLayerId)?.sprites[currentFrameIndex.value];
|
||||
if (!activeSprite || activeSprite.id !== activeSpriteId.value) return;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = cellDimensions.value;
|
||||
|
||||
const vLayers = getVisibleLayers();
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(vLayers);
|
||||
const allSprites = vLayers.flatMap(l => l.sprites);
|
||||
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
if (activeSpriteId.value === 'ALL_LAYERS') {
|
||||
// Move all sprites in current frame from all visible layers
|
||||
const visibleLayers = getVisibleLayers();
|
||||
visibleLayers.forEach(layer => {
|
||||
const sprite = layer.sprites[currentFrameIndex.value];
|
||||
if (!sprite) 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);
|
||||
const originalPos = allSpritesPosBeforeDrag.value.get(sprite.id);
|
||||
if (!originalPos) return;
|
||||
|
||||
// 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));
|
||||
// Calculate new position with constraints
|
||||
let newX = Math.round(originalPos.x + deltaX);
|
||||
let newY = Math.round(originalPos.y + deltaY);
|
||||
|
||||
emit('updateSprite', activeSpriteId.value, newX, newY);
|
||||
drawPreviewCanvas();
|
||||
// Constrain movement within expanded cell
|
||||
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 = () => {
|
||||
isDragging.value = false;
|
||||
activeSpriteId.value = null;
|
||||
activeLayerId.value = null;
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
const handleTouchStart = (event: TouchEvent, sprite: Sprite, layerId: string) => {
|
||||
if (!isDraggable.value) return;
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
@@ -365,7 +449,7 @@
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
});
|
||||
startDrag(mouseEvent);
|
||||
startDrag(mouseEvent, sprite, layerId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -388,40 +472,25 @@
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
canvas2D.initContext();
|
||||
drawPreviewCanvas();
|
||||
|
||||
// Listen for forceRedraw event from App.vue
|
||||
window.addEventListener('forceRedraw', handleForceRedraw);
|
||||
// No longer need to initialize canvas or draw
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation();
|
||||
window.removeEventListener('forceRedraw', handleForceRedraw);
|
||||
});
|
||||
|
||||
// Handler for force redraw event
|
||||
const handleForceRedraw = () => {
|
||||
const allSprites = props.layers.flatMap(l => l.sprites);
|
||||
canvas2D.ensureIntegerPositions(allSprites);
|
||||
canvas2D.applySmoothing();
|
||||
drawPreviewCanvas();
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(() => props.layers, drawPreviewCanvas, { deep: true });
|
||||
watch(currentFrameIndex, drawPreviewCanvas);
|
||||
watch(zoom, drawPreviewCanvas);
|
||||
watch(isDraggable, drawPreviewCanvas);
|
||||
watch(showAllSprites, drawPreviewCanvas);
|
||||
watch(hiddenFrames, drawPreviewCanvas);
|
||||
watch(() => settingsStore.pixelPerfect, drawPreviewCanvas);
|
||||
watch(() => settingsStore.negativeSpacingEnabled, drawPreviewCanvas);
|
||||
|
||||
// Initial draw
|
||||
if (props.layers.some(l => l.sprites.length > 0)) {
|
||||
drawPreviewCanvas();
|
||||
}
|
||||
// Watchers - most canvas-related watchers removed
|
||||
// Keep layer watchers to ensure reactivity
|
||||
watch(
|
||||
() => props.layers,
|
||||
() => {},
|
||||
{ deep: true }
|
||||
);
|
||||
watch(
|
||||
() => props.activeLayerId,
|
||||
() => {}
|
||||
);
|
||||
watch(currentFrameIndex, () => {});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<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">
|
||||
<i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'"></i>
|
||||
</button>
|
||||
<button @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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
44
src/composables/useBlog.ts
Normal file
44
src/composables/useBlog.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import matter from 'gray-matter';
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
description: string;
|
||||
image: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function useBlog() {
|
||||
const getPosts = async (): Promise<BlogPost[]> => {
|
||||
const modules = import.meta.glob('../blog/*.md', { query: '?raw', import: 'default' });
|
||||
const posts: BlogPost[] = [];
|
||||
|
||||
for (const path in modules) {
|
||||
const content = (await modules[path]()) as string;
|
||||
const { data, content: markdownContent } = matter(content);
|
||||
const slug = path.split('/').pop()?.replace('.md', '') || '';
|
||||
|
||||
posts.push({
|
||||
slug,
|
||||
title: data.title,
|
||||
date: data.date,
|
||||
description: data.description,
|
||||
image: data.image,
|
||||
content: markdownContent,
|
||||
});
|
||||
}
|
||||
|
||||
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
};
|
||||
|
||||
const getPost = async (slug: string): Promise<BlogPost | undefined> => {
|
||||
const posts = await getPosts();
|
||||
return posts.find(post => post.slug === slug);
|
||||
};
|
||||
|
||||
return {
|
||||
getPosts,
|
||||
getPost,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { calculateNegativeSpacing } from './useNegativeSpacing';
|
||||
|
||||
@@ -28,10 +28,14 @@ export interface SpritePosition {
|
||||
|
||||
export interface DragSpriteOptions {
|
||||
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
|
||||
layers?: Ref<Layer[]> | ComputedRef<Layer[]> | Layer[];
|
||||
columns: Ref<number> | number;
|
||||
zoom?: Ref<number>;
|
||||
allowCellSwap?: Ref<boolean>;
|
||||
negativeSpacingEnabled?: Ref<boolean>;
|
||||
manualCellSizeEnabled?: Ref<boolean>;
|
||||
manualCellWidth?: Ref<number>;
|
||||
manualCellHeight?: Ref<number>;
|
||||
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
|
||||
onUpdateSprite: (id: string, x: number, y: number) => void;
|
||||
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
|
||||
@@ -43,10 +47,14 @@ export function useDragSprite(options: DragSpriteOptions) {
|
||||
|
||||
// Helper to get reactive values
|
||||
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 getZoom = () => options.zoom?.value ?? 1;
|
||||
const getAllowCellSwap = () => options.allowCellSwap?.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
|
||||
const isDragging = ref(false);
|
||||
@@ -67,16 +75,34 @@ export function useDragSprite(options: DragSpriteOptions) {
|
||||
const lastMaxHeight = ref(1);
|
||||
|
||||
const calculateMaxDimensions = () => {
|
||||
const sprites = getSprites();
|
||||
const negativeSpacingEnabled = getNegativeSpacingEnabled();
|
||||
const base = getMaxDimensions(sprites);
|
||||
const baseMaxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
|
||||
const baseMaxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
|
||||
const manualCellSizeEnabled = getManualCellSizeEnabled();
|
||||
|
||||
// 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;
|
||||
lastMaxHeight.value = baseMaxHeight;
|
||||
|
||||
// Calculate negative spacing using shared composable
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites, negativeSpacingEnabled);
|
||||
const negativeSpacing = calculateNegativeSpacing(spritesToMeasure, negativeSpacingEnabled);
|
||||
|
||||
// Add negative spacing to expand each cell
|
||||
const maxWidth = baseMaxWidth + negativeSpacing;
|
||||
|
||||
@@ -6,17 +6,34 @@ import type { Sprite } from '../types/sprites';
|
||||
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>) => {
|
||||
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 { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
return {
|
||||
cellWidth: maxWidth + negativeSpacing,
|
||||
cellHeight: maxHeight + negativeSpacing,
|
||||
negativeSpacing,
|
||||
};
|
||||
};
|
||||
|
||||
const downloadSpritesheet = () => {
|
||||
if (!sprites.value.length) {
|
||||
alert('Please upload or import sprites before downloading the spritesheet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const rows = Math.ceil(sprites.value.length / columns.value);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -27,6 +44,12 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
canvas.height = cellHeight * rows;
|
||||
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) => {
|
||||
const col = index % columns.value;
|
||||
const row = Math.floor(index / columns.value);
|
||||
@@ -71,6 +94,10 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
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);
|
||||
@@ -91,6 +118,10 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
|
||||
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
|
||||
if (sprites.value.length) {
|
||||
@@ -141,10 +172,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
@@ -156,6 +184,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
|
||||
sprites.value.forEach(sprite => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Apply background color if not transparent
|
||||
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));
|
||||
gif.addFrame(ctx, { copy: true, delay: 1000 / fps });
|
||||
});
|
||||
@@ -179,10 +212,7 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const negativeSpacing = calculateNegativeSpacing(sprites.value, negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
@@ -192,6 +222,11 @@ export const useExport = (sprites: Ref<Sprite[]>, columns: Ref<number>, negative
|
||||
|
||||
sprites.value.forEach((sprite, index) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Apply background color if not transparent
|
||||
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));
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
const binary = atob(dataURL.split(',')[1]);
|
||||
|
||||
@@ -6,10 +6,31 @@ 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>) => {
|
||||
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
|
||||
@@ -32,10 +53,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
const maxLen = Math.max(...visibleLayers.map(l => l.sprites.length));
|
||||
const rows = Math.ceil(maxLen / columns.value);
|
||||
|
||||
@@ -46,6 +64,12 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
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);
|
||||
@@ -57,6 +81,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
if (!cellCtx) return;
|
||||
cellCanvas.width = cellWidth;
|
||||
cellCanvas.height = cellHeight;
|
||||
cellCtx.imageSmoothingEnabled = false;
|
||||
drawCompositeCell(cellCtx, index, cellWidth, cellHeight, negativeSpacing);
|
||||
ctx.drawImage(cellCanvas, cellX, cellY);
|
||||
}
|
||||
@@ -92,7 +117,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
})
|
||||
);
|
||||
|
||||
const json = { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, layers: layersData };
|
||||
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);
|
||||
@@ -126,6 +160,10 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
|
||||
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[] = [];
|
||||
@@ -172,10 +210,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
return;
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -210,10 +245,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
}
|
||||
const zip = new JSZip();
|
||||
|
||||
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers);
|
||||
const negativeSpacing = calculateNegativeSpacing(getAllVisibleSprites(), negativeSpacingEnabled.value);
|
||||
const cellWidth = maxWidth + negativeSpacing;
|
||||
const cellHeight = maxHeight + negativeSpacing;
|
||||
const { cellWidth, cellHeight, negativeSpacing } = getCellDimensions();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -244,7 +276,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
||||
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, layers: layersPayload };
|
||||
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);
|
||||
})();
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useFileDrop(options: FileDropOptions) {
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent, canvasRef?: HTMLCanvasElement | null) => {
|
||||
const handleDragLeave = (event: DragEvent, canvasRef?: HTMLElement | null) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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(),
|
||||
@@ -10,10 +11,12 @@ export const createEmptyLayer = (name: string): Layer => ({
|
||||
locked: false,
|
||||
});
|
||||
|
||||
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
|
||||
const activeLayerId = ref<string>(layers.value[0].id);
|
||||
const columns = ref(4);
|
||||
|
||||
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));
|
||||
@@ -25,6 +28,8 @@ export const useLayers = () => {
|
||||
|
||||
const getMaxDimensions = (sprites: Sprite[]) => getMaxDimensionsSingle(sprites);
|
||||
|
||||
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
|
||||
|
||||
const updateSpritePosition = (id: string, x: number, y: number) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
@@ -35,10 +40,35 @@ export const useLayers = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(l.sprites);
|
||||
|
||||
// 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;
|
||||
@@ -47,19 +77,19 @@ export const useLayers = () => {
|
||||
x = 0;
|
||||
break;
|
||||
case 'center':
|
||||
x = Math.floor((maxWidth - sprite.width) / 2);
|
||||
x = Math.floor((cellWidth - sprite.width) / 2);
|
||||
break;
|
||||
case 'right':
|
||||
x = Math.floor(maxWidth - sprite.width);
|
||||
x = Math.floor(cellWidth - sprite.width);
|
||||
break;
|
||||
case 'top':
|
||||
y = 0;
|
||||
break;
|
||||
case 'middle':
|
||||
y = Math.floor((maxHeight - sprite.height) / 2);
|
||||
y = Math.floor((cellHeight - sprite.height) / 2);
|
||||
break;
|
||||
case 'bottom':
|
||||
y = Math.floor(maxHeight - sprite.height);
|
||||
y = Math.floor(cellHeight - sprite.height);
|
||||
break;
|
||||
}
|
||||
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
||||
@@ -110,43 +140,57 @@ export const useLayers = () => {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
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 };
|
||||
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;
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read replacement image file:', file.name);
|
||||
};
|
||||
img.src = url;
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const addSprite = (file: File) => addSpriteWithResize(file);
|
||||
|
||||
const addSpriteWithResize = (file: File) => {
|
||||
const addSprite = (file: File) => {
|
||||
const l = activeLayer.value;
|
||||
if (!l) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
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,
|
||||
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];
|
||||
};
|
||||
l.sprites = [...l.sprites, next];
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load sprite image:', file.name);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
img.onerror = () => URL.revokeObjectURL(url);
|
||||
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) addSpriteWithResize(f);
|
||||
for (const f of files) addSprite(f);
|
||||
};
|
||||
|
||||
const addLayer = (name?: string) => {
|
||||
@@ -176,8 +220,6 @@ export const useLayers = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
|
||||
|
||||
return {
|
||||
layers,
|
||||
visibleLayers,
|
||||
@@ -186,11 +228,11 @@ export const useLayers = () => {
|
||||
columns,
|
||||
getMaxDimensions,
|
||||
updateSpritePosition,
|
||||
updateSpriteInLayer,
|
||||
updateSpriteCell,
|
||||
removeSprite,
|
||||
replaceSprite,
|
||||
addSprite,
|
||||
addSpriteWithResize,
|
||||
processImageFiles,
|
||||
alignSprites,
|
||||
addLayer,
|
||||
|
||||
@@ -87,129 +87,157 @@ export const useSprites = () => {
|
||||
const old = sprites.value[i];
|
||||
revokeIfBlob(old.url);
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const next: Sprite = {
|
||||
id: old.id,
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: old.x,
|
||||
y: old.y,
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const url = e.target?.result as string;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const next: Sprite = {
|
||||
id: old.id,
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: old.x,
|
||||
y: old.y,
|
||||
};
|
||||
const arr = [...sprites.value];
|
||||
arr[i] = next;
|
||||
sprites.value = arr;
|
||||
};
|
||||
const arr = [...sprites.value];
|
||||
arr[i] = next;
|
||||
sprites.value = arr;
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load replacement image:', file.name);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load replacement image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read replacement image file:', file.name);
|
||||
};
|
||||
img.src = url;
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const addSprite = (file: File) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const s: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const url = e.target?.result as string;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const s: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
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 = () => {
|
||||
console.error('Failed to load new sprite image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read sprite image file:', file.name);
|
||||
};
|
||||
img.src = url;
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const addSpriteWithResize = (file: File) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const url = e.target?.result as string;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const { maxWidth, maxHeight } = getMaxDimensions(sprites.value);
|
||||
|
||||
const newSprite: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
const newSprite: Sprite = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 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();
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
console.error('Failed to load new sprite image:', file.name);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load new sprite image:', file.name);
|
||||
URL.revokeObjectURL(url);
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read sprite image file:', file.name);
|
||||
};
|
||||
img.src = url;
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const processImageFiles = (files: File[]) => {
|
||||
Promise.all(
|
||||
files.map(
|
||||
file =>
|
||||
new Promise<Sprite>(resolve => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
new Promise<Sprite>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const url = e.target?.result as string;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
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(() => {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Buffer } from 'buffer';
|
||||
// @ts-ignore
|
||||
window.Buffer = Buffer;
|
||||
|
||||
import './assets/main.css';
|
||||
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
|
||||
import router from './router';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
47
src/router/index.ts
Normal file
47
src/router/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import HomeView from '../views/HomeView.vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () => import('../views/AboutUs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/contact',
|
||||
name: 'contact',
|
||||
component: () => import('../views/Contact.vue'),
|
||||
},
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
name: 'privacy-policy',
|
||||
component: () => import('../views/PrivacyPolicy.vue'),
|
||||
},
|
||||
{
|
||||
path: '/blog',
|
||||
name: 'blog-overview',
|
||||
component: () => import('../views/BlogOverview.vue'),
|
||||
},
|
||||
{
|
||||
path: '/blog/:slug',
|
||||
name: 'blog-detail',
|
||||
component: () => import('../views/BlogDetail.vue'),
|
||||
},
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
} else {
|
||||
return { top: 0 };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,10 @@ const pixelPerfect = ref(true);
|
||||
const darkMode = 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
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -61,16 +65,46 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
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 {
|
||||
pixelPerfect,
|
||||
darkMode,
|
||||
negativeSpacingEnabled,
|
||||
backgroundColor,
|
||||
manualCellSizeEnabled,
|
||||
manualCellWidth,
|
||||
manualCellHeight,
|
||||
checkerboardEnabled,
|
||||
togglePixelPerfect,
|
||||
setPixelPerfect,
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
toggleNegativeSpacing,
|
||||
setBackgroundColor,
|
||||
toggleManualCellSize,
|
||||
setManualCellWidth,
|
||||
setManualCellHeight,
|
||||
setManualCellSize,
|
||||
toggleCheckerboard,
|
||||
};
|
||||
});
|
||||
|
||||
17
src/views/AboutUs.vue
Normal file
17
src/views/AboutUs.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="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 p-8 sm:p-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-900 dark:text-white mb-6">About Us</h1>
|
||||
<div class="space-y-6">
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">Welcome to Spritesheet Generator, a tool designed to help game developers and artists streamline their workflow.</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
Our mission is to provide a simple, powerful, and free tool for creating optimized spritesheets directly in your browser. Whether you are an indie developer, a hobbyist, or part of a large studio, we hope this tool makes your life easier.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Our Story</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">This project started as a small utility for personal game jams and has grown into a full-featured spritesheet packer. We believe in open source and community-driven development.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
44
src/views/BlogDetail.vue
Normal file
44
src/views/BlogDetail.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useBlog, type BlogPost } from '../composables/useBlog';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { getPost } = useBlog();
|
||||
const post = ref<BlogPost | undefined>(undefined);
|
||||
const renderedContent = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
const slug = route.params.slug as string;
|
||||
post.value = await getPost(slug);
|
||||
|
||||
if (post.value) {
|
||||
renderedContent.value = await marked(post.value.content);
|
||||
} else {
|
||||
router.push({ name: 'blog-overview' });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div v-if="post">
|
||||
<RouterLink :to="{ name: 'blog-overview' }" class="inline-flex items-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-6 transition-colors">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to overview
|
||||
</RouterLink>
|
||||
|
||||
<h1 class="text-4xl font-bold mb-4 text-gray-900 dark:text-white">{{ post.title }}</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-8">{{ post.date }}</p>
|
||||
<!-- Image is intentionally omitted here as per requirements -->
|
||||
<div class="prose max-w-none" v-html="renderedContent"></div>
|
||||
</div>
|
||||
<div v-else class="text-center py-12">
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
30
src/views/BlogOverview.vue
Normal file
30
src/views/BlogOverview.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useBlog, type BlogPost } from '../composables/useBlog';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
const { getPosts } = useBlog();
|
||||
const posts = ref<BlogPost[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
posts.value = await getPosts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<h1 class="text-4xl font-bold mb-8 text-gray-900 dark:text-white">Blog</h1>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<article v-for="post in posts" :key="post.slug" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300 flex flex-col h-full">
|
||||
<RouterLink :to="{ name: 'blog-detail', params: { slug: post.slug } }" class="flex flex-col h-full">
|
||||
<img :src="post.image" :alt="post.title" class="w-full h-48 object-cover" />
|
||||
<div class="p-6 flex-1 flex flex-col">
|
||||
<h2 class="text-xl font-bold mb-2 text-gray-900 dark:text-white line-clamp-2">{{ post.title }}</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-xs mb-3">{{ post.date }}</p>
|
||||
<p class="text-gray-700 dark:text-gray-300 text-sm line-clamp-3 flex-1">{{ post.description }}</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
27
src/views/Contact.vue
Normal file
27
src/views/Contact.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="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 p-8 sm:p-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-900 dark:text-white mb-6">Contact Us</h1>
|
||||
<div class="space-y-6">
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We'd love to hear from you! Whether you have a question, feedback, or just want to say hi, feel free to reach out.</p>
|
||||
<div class="flex flex-col gap-4 mt-8">
|
||||
<a href="https://discord.gg/JTev3nzeDa" target="_blank" class="flex items-center gap-3 p-4 bg-gray-100 dark:bg-gray-800 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors no-underline">
|
||||
<i class="fab fa-discord text-2xl text-[#5865F2]"></i>
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white">Join our Discord</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Chat with the community and developers</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="https://gitea.adhd.sh/root/spritesheet-generator" target="_blank" class="flex items-center gap-3 p-4 bg-gray-100 dark:bg-gray-800 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors no-underline">
|
||||
<i class="fab fa-github text-2xl text-gray-900 dark:text-white"></i>
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white">Source Code</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Report bugs or contribute on Gitea</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
425
src/views/HomeView.vue
Normal file
425
src/views/HomeView.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<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) }">
|
||||
<!-- Welcome state -->
|
||||
<div v-if="!layers.some(l => l.sprites.length)" class="p-6 sm:p-10">
|
||||
<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" />
|
||||
|
||||
<div class="mt-10">
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<!-- Right panel - Tabs -->
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<!-- Tab Navigation -->
|
||||
<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>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="p-6 lg:flex-1 lg:overflow-auto">
|
||||
<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>
|
||||
|
||||
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
|
||||
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, toRef, computed } from 'vue';
|
||||
import FileUploader from '@/components/FileUploader.vue';
|
||||
import SpriteCanvas from '@/components/SpriteCanvas.vue';
|
||||
import SpritePreview from '@/components/SpritePreview.vue';
|
||||
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
|
||||
import GifFpsModal from '@/components/GifFpsModal.vue';
|
||||
import { useExportLayers } from '@/composables/useExportLayers';
|
||||
import { useLayers } from '@/composables/useLayers';
|
||||
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
|
||||
import type { SpriteFile } from '@/types/sprites';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
|
||||
|
||||
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
|
||||
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 isSpritesheetSplitterOpen = ref(false);
|
||||
const isGifFpsModalOpen = ref(false);
|
||||
const uploadInput = ref<HTMLInputElement | null>(null);
|
||||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||||
const spritesheetImageUrl = ref('');
|
||||
const spritesheetImageFile = ref<File | null>(null);
|
||||
const editingLayerId = ref<string | null>(null);
|
||||
const editingLayerName = ref('');
|
||||
const layerNameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const handleSpritesUpload = async (files: File[]) => {
|
||||
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
|
||||
|
||||
if (jsonFile) {
|
||||
await handleJSONImport(jsonFile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 1 && files[0].type.startsWith('image/')) {
|
||||
const file = files[0];
|
||||
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;
|
||||
}
|
||||
|
||||
processImageFiles([file]);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load image:', file.name);
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read image file:', file.name);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
return;
|
||||
}
|
||||
|
||||
processImageFiles(files);
|
||||
};
|
||||
|
||||
const handleJSONImport = async (jsonFile: File) => {
|
||||
try {
|
||||
await importSpritesheetJSON(jsonFile);
|
||||
} catch (error) {
|
||||
console.error('Error importing JSON:', error);
|
||||
alert('Failed to import JSON file. Please check the file format.');
|
||||
}
|
||||
};
|
||||
|
||||
const closeSpritesheetSplitter = () => {
|
||||
isSpritesheetSplitterOpen.value = false;
|
||||
if (spritesheetImageUrl.value && spritesheetImageUrl.value.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(spritesheetImageUrl.value);
|
||||
} catch {}
|
||||
}
|
||||
spritesheetImageUrl.value = '';
|
||||
spritesheetImageFile.value = null;
|
||||
};
|
||||
|
||||
const openGifFpsModal = () => {
|
||||
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||
alert('Please upload or import sprites before generating a GIF.');
|
||||
return;
|
||||
}
|
||||
isGifFpsModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeGifFpsModal = () => {
|
||||
isGifFpsModalOpen.value = false;
|
||||
};
|
||||
|
||||
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
||||
processImageFiles(spriteFiles.map(s => s.file));
|
||||
};
|
||||
|
||||
const openJSONImportDialog = () => {
|
||||
jsonFileInput.value?.click();
|
||||
};
|
||||
|
||||
const handleJSONFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
await handleJSONImport(input.files[0]);
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
35
src/views/PrivacyPolicy.vue
Normal file
35
src/views/PrivacyPolicy.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="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 p-8 sm:p-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-900 dark:text-white mb-6">Privacy Policy</h1>
|
||||
<div class="space-y-6">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-8">Last updated: November 26, 2025</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">1. Introduction</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We respect your privacy. This Spritesheet Generator is a client-side tool, meaning your images are processed directly in your browser and are not uploaded to our servers.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">2. Data Collection</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We do not collect any personal data or uploaded images. All processing happens locally on your device.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">3. Local Storage</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We use your browser's Local Storage to save your preferences (such as dark mode and grid settings) to improve your experience.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">4. Third-Party Services</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We may use third-party services for analytics or hosting (e.g., Cloudflare, Vercel) which may collect standard server logs.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">5. Contact</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">If you have any questions about this Privacy Policy, please contact us via our Discord server.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -68,5 +68,7 @@ export default {
|
||||
}),
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user