mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-17 17:37:55 -05:00
Compare commits
544 Commits
v4.2.9.dev
...
v4.2.9.dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ed567aad | ||
|
|
9692a5f996 | ||
|
|
5db7b48cd8 | ||
|
|
ea014c66ac | ||
|
|
25918c28aa | ||
|
|
0c60469401 | ||
|
|
f1aa50f447 | ||
|
|
a413b261f0 | ||
|
|
4a1a6639f6 | ||
|
|
201c370ca1 | ||
|
|
d070c7c726 | ||
|
|
e38e20a992 | ||
|
|
39a94ec70e | ||
|
|
c7bfae2d1e | ||
|
|
e7944c427d | ||
|
|
48ed4e120d | ||
|
|
a5b038a1b1 | ||
|
|
dc752c98b0 | ||
|
|
85a47cc6fe | ||
|
|
6450f42cfa | ||
|
|
3876f71ff4 | ||
|
|
cf819e8eab | ||
|
|
2217fb8485 | ||
|
|
43652e830a | ||
|
|
a3417bf81d | ||
|
|
06637161e3 | ||
|
|
c4f4b16a36 | ||
|
|
3001718f9f | ||
|
|
ac16fa65a3 | ||
|
|
bc0b5335ff | ||
|
|
e91c7c5a30 | ||
|
|
74791cc490 | ||
|
|
68409c6a0f | ||
|
|
85613b220c | ||
|
|
80085ad854 | ||
|
|
b6bfa65104 | ||
|
|
0bfa033089 | ||
|
|
6f0974b5bc | ||
|
|
c3b53fc4f6 | ||
|
|
8f59a32d81 | ||
|
|
3b4f20f433 | ||
|
|
73da6e9628 | ||
|
|
2fc482141d | ||
|
|
a41ec5f3fc | ||
|
|
c906225d03 | ||
|
|
2985ea3716 | ||
|
|
4f151c6c6f | ||
|
|
c4f5252c1a | ||
|
|
77c13f2cf3 | ||
|
|
3270d36fca | ||
|
|
b6b30ff01f | ||
|
|
aa9bfdff35 | ||
|
|
80308cc3b8 | ||
|
|
f6db73bf1f | ||
|
|
ef9f61a39f | ||
|
|
a1a0881133 | ||
|
|
9956919ab6 | ||
|
|
abc07f57d6 | ||
|
|
1a1cae79f1 | ||
|
|
bcfafe7b06 | ||
|
|
34e8ced592 | ||
|
|
1fdada65b6 | ||
|
|
433f3e1971 | ||
|
|
a60e23f825 | ||
|
|
f69de3148e | ||
|
|
cbcd36ef54 | ||
|
|
aa76134340 | ||
|
|
55758acae8 | ||
|
|
196e43b5e5 | ||
|
|
38b9828441 | ||
|
|
0048a7077e | ||
|
|
527a39a3ad | ||
|
|
30ce4c55c7 | ||
|
|
ca082d4288 | ||
|
|
5e59a4f43a | ||
|
|
9f86605049 | ||
|
|
79058a7894 | ||
|
|
bb3ad8c2f1 | ||
|
|
799688514b | ||
|
|
b7344b0df2 | ||
|
|
7e382c5f3f | ||
|
|
9cf357e184 | ||
|
|
95b6c773d4 | ||
|
|
89d8c5ba00 | ||
|
|
59580cf6ed | ||
|
|
2b0c084f5b | ||
|
|
4d896073ff | ||
|
|
9f69503a80 | ||
|
|
0311e852a0 | ||
|
|
7003a3d546 | ||
|
|
dc73072e27 | ||
|
|
e549c44ad7 | ||
|
|
45a4231cbe | ||
|
|
81f046ebac | ||
|
|
6ef6c593c4 | ||
|
|
5b53eefef7 | ||
|
|
9a9919c0af | ||
|
|
10661b33d4 | ||
|
|
52193d604d | ||
|
|
2568441e6a | ||
|
|
1a14860b3b | ||
|
|
9ff7647ec5 | ||
|
|
b49106e8fe | ||
|
|
906d0902a3 | ||
|
|
fbde6f5a7f | ||
|
|
b388268987 | ||
|
|
3b4164bd62 | ||
|
|
b7fc6fe573 | ||
|
|
2954a19d27 | ||
|
|
aa45ce7fbd | ||
|
|
77e5078e4a | ||
|
|
603cc7bf2e | ||
|
|
cd517a102d | ||
|
|
9a442918b5 | ||
|
|
f9c03d85a5 | ||
|
|
10d07c71c4 | ||
|
|
cd05a78219 | ||
|
|
f8ee572abc | ||
|
|
d918654509 | ||
|
|
582e30c542 | ||
|
|
34a6555301 | ||
|
|
fff860090b | ||
|
|
f4971197c1 | ||
|
|
621d5e0462 | ||
|
|
0b68a69a6c | ||
|
|
9a599ce595 | ||
|
|
1467ba276f | ||
|
|
708facf707 | ||
|
|
9c6c6adb1f | ||
|
|
c335b8581c | ||
|
|
f1348e45bd | ||
|
|
ce6cf9b079 | ||
|
|
13ec80736a | ||
|
|
c9690a4b21 | ||
|
|
489e875a6e | ||
|
|
8651396048 | ||
|
|
2bab5a6179 | ||
|
|
006f06b615 | ||
|
|
d603923d1b | ||
|
|
86878e855b | ||
|
|
35de60a8fa | ||
|
|
2c444a1941 | ||
|
|
3dfef01889 | ||
|
|
a845a2daa5 | ||
|
|
df41f4fbce | ||
|
|
76482da6f5 | ||
|
|
8205abbbbf | ||
|
|
926873de26 | ||
|
|
00cb1903ba | ||
|
|
58ba38b9c7 | ||
|
|
2f6a5617f9 | ||
|
|
e0d84743be | ||
|
|
ee7c62acc4 | ||
|
|
daf3e58bd9 | ||
|
|
c5b9209057 | ||
|
|
2a4d6d98e2 | ||
|
|
cfdf59d906 | ||
|
|
f91ce1a47c | ||
|
|
4af2888168 | ||
|
|
8471c6fe86 | ||
|
|
fe65a5a2db | ||
|
|
0df26e967c | ||
|
|
d4822b305e | ||
|
|
8df5447563 | ||
|
|
7b5a43df9b | ||
|
|
61ef630175 | ||
|
|
4eda2ef555 | ||
|
|
57f4489520 | ||
|
|
fb6cf9e3da | ||
|
|
f776326cff | ||
|
|
5be32d5733 | ||
|
|
1f73435241 | ||
|
|
3251a00631 | ||
|
|
49c4ad1dd7 | ||
|
|
5857e95c4a | ||
|
|
85be2532c6 | ||
|
|
8b81a00def | ||
|
|
8544595c27 | ||
|
|
a6a5d1470c | ||
|
|
febcc12ec9 | ||
|
|
ab64078b76 | ||
|
|
0ff3459b07 | ||
|
|
2abd7c9bfe | ||
|
|
8e5330bdc9 | ||
|
|
1ecec4ea3a | ||
|
|
700dbe69f3 | ||
|
|
af7ba3b7e4 | ||
|
|
1f7144d62e | ||
|
|
4e389e415b | ||
|
|
2db29fb6ab | ||
|
|
79653fcff5 | ||
|
|
9e39180fbc | ||
|
|
dc0f832d8f | ||
|
|
0dcd6aa5d9 | ||
|
|
9f4a8f11f8 | ||
|
|
6f9579d6ec | ||
|
|
2b2aabb234 | ||
|
|
a79b9633ab | ||
|
|
7b628c908b | ||
|
|
181703a709 | ||
|
|
c439e3c204 | ||
|
|
11e81eb456 | ||
|
|
3e24bf640e | ||
|
|
a17664fb75 | ||
|
|
5aed23dc91 | ||
|
|
7f389716d0 | ||
|
|
eca13b674a | ||
|
|
7c982a1bdf | ||
|
|
bdfe6870fd | ||
|
|
7eebbc0dd9 | ||
|
|
6ae46d7c8b | ||
|
|
8aae30566e | ||
|
|
4b7c3e221c | ||
|
|
4015795b7f | ||
|
|
132dd61d8d | ||
|
|
6f2b548dd1 | ||
|
|
49dd316f17 | ||
|
|
e0a8bb149d | ||
|
|
020b6db34b | ||
|
|
f6d2f0bf8c | ||
|
|
1280cce803 | ||
|
|
82463d12e2 | ||
|
|
ba6c1b84e4 | ||
|
|
92b6d3198a | ||
|
|
693ae1af50 | ||
|
|
3873a3096c | ||
|
|
4a9f6ab5ef | ||
|
|
2fc29c5125 | ||
|
|
5fbc876cfd | ||
|
|
89b0673ac9 | ||
|
|
3179a16189 | ||
|
|
3ce216b391 | ||
|
|
8a3a94e21a | ||
|
|
f61af188f9 | ||
|
|
73fc52bfed | ||
|
|
4eeff4eef8 | ||
|
|
cff2c43030 | ||
|
|
504b1f2425 | ||
|
|
eff5b56990 | ||
|
|
0e673f1a18 | ||
|
|
4fea22aea4 | ||
|
|
c2478c9ac3 | ||
|
|
ed8243825e | ||
|
|
c7ac2b5278 | ||
|
|
a783003556 | ||
|
|
44ba1c6113 | ||
|
|
a02d67fcc6 | ||
|
|
14c8f7c4f5 | ||
|
|
a487ecb50f | ||
|
|
fc55862823 | ||
|
|
e5400601d6 | ||
|
|
735f9f1483 | ||
|
|
d139db0a0f | ||
|
|
a35bb450b1 | ||
|
|
26c01dfa48 | ||
|
|
2b1839374a | ||
|
|
59f5f18e1d | ||
|
|
e41fcb081c | ||
|
|
3032042b35 | ||
|
|
544db61044 | ||
|
|
67a3aa6dff | ||
|
|
34f4468b20 | ||
|
|
d99ae58001 | ||
|
|
d60ec53762 | ||
|
|
0ece9361d5 | ||
|
|
69987a2f00 | ||
|
|
7346bfccb9 | ||
|
|
b705083ce2 | ||
|
|
4b3c82df6f | ||
|
|
46290205d5 | ||
|
|
7b15585b80 | ||
|
|
cbfeeb9079 | ||
|
|
fdac20b43e | ||
|
|
ae2312104e | ||
|
|
74b6674af6 | ||
|
|
81adce3238 | ||
|
|
48b7f460a8 | ||
|
|
38a8232341 | ||
|
|
39a51c4f4c | ||
|
|
220bfeb37d | ||
|
|
e766279950 | ||
|
|
db82406525 | ||
|
|
2d44f332d9 | ||
|
|
ea1526689a | ||
|
|
200338ed72 | ||
|
|
88003a61bd | ||
|
|
b82089b30b | ||
|
|
efd780d395 | ||
|
|
3f90f783de | ||
|
|
4f5b755117 | ||
|
|
a455f12581 | ||
|
|
de516db383 | ||
|
|
6781575293 | ||
|
|
65b0e40fc8 | ||
|
|
19e78a07b7 | ||
|
|
e3b60dda07 | ||
|
|
a9696d3193 | ||
|
|
336d72873f | ||
|
|
111a380bce | ||
|
|
012a8351af | ||
|
|
deeb80ea9b | ||
|
|
1e97a917d6 | ||
|
|
a15ba925db | ||
|
|
6e5a968aad | ||
|
|
915edaa02f | ||
|
|
f6b0fa7c18 | ||
|
|
3f1fba0f35 | ||
|
|
d9fa85a4c6 | ||
|
|
022bb8649c | ||
|
|
673bc33a01 | ||
|
|
579a64928d | ||
|
|
5316df7d7d | ||
|
|
62fe61dc30 | ||
|
|
ea819a4a2f | ||
|
|
868a25dae2 | ||
|
|
3a25d00cb6 | ||
|
|
6301b74d87 | ||
|
|
b43b90c299 | ||
|
|
0a43444ab3 | ||
|
|
75ea4d2155 | ||
|
|
9d281388e0 | ||
|
|
178e1cc50b | ||
|
|
6eafe53b2d | ||
|
|
7514b2b7d4 | ||
|
|
51dee2dba2 | ||
|
|
a81c3d841c | ||
|
|
154487f966 | ||
|
|
588daafcf5 | ||
|
|
ab00097aed | ||
|
|
5aefae71a2 | ||
|
|
0edd598970 | ||
|
|
0bb485031f | ||
|
|
01ffd86367 | ||
|
|
e2e02f31b6 | ||
|
|
0008617348 | ||
|
|
f8f21c0edd | ||
|
|
010916158b | ||
|
|
df0ba004ca | ||
|
|
038b29e15b | ||
|
|
763ab73923 | ||
|
|
9a060b4437 | ||
|
|
2307a30892 | ||
|
|
2006f84f6e | ||
|
|
23b15fef6a | ||
|
|
41e72f929d | ||
|
|
29fc49bb3b | ||
|
|
6f65b6a40f | ||
|
|
1e9b22e3a4 | ||
|
|
3dc2c723c3 | ||
|
|
6f007cbd48 | ||
|
|
9a402dd10e | ||
|
|
4249d0e13b | ||
|
|
f6050bad67 | ||
|
|
d3a4b7b51b | ||
|
|
3f5f9ac764 | ||
|
|
e7c1299a7f | ||
|
|
56a3918a1e | ||
|
|
dabf7718cf | ||
|
|
ef22c29288 | ||
|
|
74f06074f7 | ||
|
|
b97bf52faa | ||
|
|
0a77f5cec8 | ||
|
|
f8e92f7b73 | ||
|
|
530c6e3a59 | ||
|
|
11b95cfaf4 | ||
|
|
707c005a26 | ||
|
|
19fa8e7e33 | ||
|
|
b252ded366 | ||
|
|
84305d4e73 | ||
|
|
1150b41e14 | ||
|
|
88d8ccb34b | ||
|
|
4111b3f1aa | ||
|
|
36862be2aa | ||
|
|
425665e0d9 | ||
|
|
9abd604f69 | ||
|
|
59bdc288b5 | ||
|
|
eb37d2958e | ||
|
|
2a9738a341 | ||
|
|
6aac1cf33a | ||
|
|
9ca4d072ab | ||
|
|
7aaf14c26b | ||
|
|
cf598ca175 | ||
|
|
a722790afc | ||
|
|
320151a040 | ||
|
|
c090f511c3 | ||
|
|
86dd1475b3 | ||
|
|
0b71ac258c | ||
|
|
54e1eae509 | ||
|
|
bf57b2dc77 | ||
|
|
de3c27b44f | ||
|
|
05717fea93 | ||
|
|
191584d229 | ||
|
|
6069169e6b | ||
|
|
07438587f3 | ||
|
|
913e36d6fd | ||
|
|
139004b976 | ||
|
|
ef4269d585 | ||
|
|
954cb129a4 | ||
|
|
02c4b28de5 | ||
|
|
febea88b58 | ||
|
|
40ccfac514 | ||
|
|
831fb814cc | ||
|
|
bf166fdd61 | ||
|
|
384bde3539 | ||
|
|
6f1d238d0a | ||
|
|
ac524153a7 | ||
|
|
2cad2b15cf | ||
|
|
fd63e202fe | ||
|
|
a0250e47e3 | ||
|
|
7d8ece45bb | ||
|
|
ffb8f053da | ||
|
|
fb46f457f9 | ||
|
|
6d4f4152a7 | ||
|
|
d3d0ac7327 | ||
|
|
f57df46995 | ||
|
|
a747171745 | ||
|
|
e9ae9e80d4 | ||
|
|
3b9a59b98d | ||
|
|
8a381e7f74 | ||
|
|
61513fc800 | ||
|
|
1d26c49e92 | ||
|
|
77be9836d2 | ||
|
|
4427960acb | ||
|
|
84aa4fb7bc | ||
|
|
01df96cbe0 | ||
|
|
1ac0634f57 | ||
|
|
8e7d3634b1 | ||
|
|
fadafe5c77 | ||
|
|
b2ea1f6690 | ||
|
|
0a03c1f882 | ||
|
|
ce8b490ed8 | ||
|
|
86eccba80d | ||
|
|
97453e7c6c | ||
|
|
9e1084b701 | ||
|
|
41aec81f3f | ||
|
|
a5741a0551 | ||
|
|
72f73c231a | ||
|
|
b8fcaa274e | ||
|
|
a33bbf48bb | ||
|
|
98d9490fb9 | ||
|
|
51c643c4f8 | ||
|
|
2d7370ca6c | ||
|
|
0d68141387 | ||
|
|
e88a8c6639 | ||
|
|
2d04bb286e | ||
|
|
6d9ba24c32 | ||
|
|
7645a1c86e | ||
|
|
dc284b9a48 | ||
|
|
8a38332d44 | ||
|
|
2407d7d148 | ||
|
|
23275cf607 | ||
|
|
8a0e02d335 | ||
|
|
913873623c | ||
|
|
5d81e7dd4d | ||
|
|
418650fdf3 | ||
|
|
7c24e56d9f | ||
|
|
f6faed46c3 | ||
|
|
1d393eecf1 | ||
|
|
03a72240c0 | ||
|
|
acf62450fb | ||
|
|
d0269310cf | ||
|
|
43618a74e7 | ||
|
|
56fed637ec | ||
|
|
944ae4a604 | ||
|
|
f3da609102 | ||
|
|
11c8a8cf72 | ||
|
|
656978676f | ||
|
|
d216e6519f | ||
|
|
0a2bcae0e3 | ||
|
|
31cf244420 | ||
|
|
d761effac1 | ||
|
|
5efaf2c661 | ||
|
|
b79161fbec | ||
|
|
89a2f2134b | ||
|
|
343c3b19b1 | ||
|
|
e6ec646b2c | ||
|
|
b557fe4e40 | ||
|
|
45908dfbd2 | ||
|
|
1cf0673a22 | ||
|
|
189847f8a5 | ||
|
|
b27929f702 | ||
|
|
b1a6a9835d | ||
|
|
5564a16d4b | ||
|
|
b2aa447d50 | ||
|
|
8666f9a848 | ||
|
|
e7dc7c4917 | ||
|
|
513d0f1e5c | ||
|
|
c6ce7618cf | ||
|
|
9b78b8dc91 | ||
|
|
3bae233f40 | ||
|
|
0a305c4291 | ||
|
|
54fe4ddf3e | ||
|
|
aa877b981c | ||
|
|
b8ec4348a5 | ||
|
|
0e3e27668c | ||
|
|
e8c8025119 | ||
|
|
6a72eda5d2 | ||
|
|
35c8c54466 | ||
|
|
2eda45ca5b | ||
|
|
ecd6e7960c | ||
|
|
0d3a61cbdb | ||
|
|
6737c275d7 | ||
|
|
201a3e5838 | ||
|
|
85cb239219 | ||
|
|
002f45e383 | ||
|
|
470e5ba290 | ||
|
|
b6722b3a10 | ||
|
|
6a624916ca | ||
|
|
65653e0932 | ||
|
|
a7e59d6697 | ||
|
|
f1356105c1 | ||
|
|
8acc6379fb | ||
|
|
d13014c5d9 | ||
|
|
db11d8ba90 | ||
|
|
46a4ee2360 | ||
|
|
a19c053b88 | ||
|
|
5b47a32d31 | ||
|
|
32ae9efb6a | ||
|
|
b81bee551a | ||
|
|
8c9dffd082 | ||
|
|
cdfae643e4 | ||
|
|
cb93108206 | ||
|
|
c11343dc1c | ||
|
|
3733c6f89d | ||
|
|
3c23a0eac0 | ||
|
|
7fd69ab1f1 | ||
|
|
3f511774de | ||
|
|
3600100879 | ||
|
|
8fae372103 | ||
|
|
2fce1fe04c | ||
|
|
22f3e975c7 | ||
|
|
c6ced5a210 | ||
|
|
5d66e85205 | ||
|
|
649f163bf7 | ||
|
|
4c821bd930 | ||
|
|
6359de4e25 | ||
|
|
2c610c8cd4 | ||
|
|
7efe8a249b | ||
|
|
bd421d184e | ||
|
|
78f5844ba0 | ||
|
|
f6bb4d5051 | ||
|
|
56642c3e87 | ||
|
|
9fd8678d3d | ||
|
|
8b89518fd6 |
@@ -45,13 +45,11 @@ class UIType(str, Enum, metaclass=MetaEnum):
|
||||
SDXLRefinerModel = "SDXLRefinerModelField"
|
||||
ONNXModel = "ONNXModelField"
|
||||
VAEModel = "VAEModelField"
|
||||
FluxVAEModel = "FluxVAEModelField"
|
||||
LoRAModel = "LoRAModelField"
|
||||
ControlNetModel = "ControlNetModelField"
|
||||
IPAdapterModel = "IPAdapterModelField"
|
||||
T2IAdapterModel = "T2IAdapterModelField"
|
||||
T5EncoderModel = "T5EncoderModelField"
|
||||
CLIPEmbedModel = "CLIPEmbedModelField"
|
||||
SpandrelImageToImageModel = "SpandrelImageToImageModelField"
|
||||
# endregion
|
||||
|
||||
@@ -130,7 +128,6 @@ class FieldDescriptions:
|
||||
noise = "Noise tensor"
|
||||
clip = "CLIP (tokenizer, text encoder, LoRAs) and skipped layer count"
|
||||
t5_encoder = "T5 tokenizer and text encoder"
|
||||
clip_embed_model = "CLIP Embed loader"
|
||||
unet = "UNet (scheduler, LoRAs)"
|
||||
transformer = "Transformer"
|
||||
vae = "VAE"
|
||||
|
||||
@@ -40,10 +40,7 @@ class FluxTextEncoderInvocation(BaseInvocation):
|
||||
|
||||
@torch.no_grad()
|
||||
def invoke(self, context: InvocationContext) -> FluxConditioningOutput:
|
||||
# Note: The T5 and CLIP encoding are done in separate functions to ensure that all model references are locally
|
||||
# scoped. This ensures that the T5 model can be freed and gc'd before loading the CLIP model (if necessary).
|
||||
t5_embeddings = self._t5_encode(context)
|
||||
clip_embeddings = self._clip_encode(context)
|
||||
t5_embeddings, clip_embeddings = self._encode_prompt(context)
|
||||
conditioning_data = ConditioningFieldData(
|
||||
conditionings=[FLUXConditioningInfo(clip_embeds=clip_embeddings, t5_embeds=t5_embeddings)]
|
||||
)
|
||||
@@ -51,7 +48,12 @@ class FluxTextEncoderInvocation(BaseInvocation):
|
||||
conditioning_name = context.conditioning.save(conditioning_data)
|
||||
return FluxConditioningOutput.build(conditioning_name)
|
||||
|
||||
def _t5_encode(self, context: InvocationContext) -> torch.Tensor:
|
||||
def _encode_prompt(self, context: InvocationContext) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
# Load CLIP.
|
||||
clip_tokenizer_info = context.models.load(self.clip.tokenizer)
|
||||
clip_text_encoder_info = context.models.load(self.clip.text_encoder)
|
||||
|
||||
# Load T5.
|
||||
t5_tokenizer_info = context.models.load(self.t5_encoder.tokenizer)
|
||||
t5_text_encoder_info = context.models.load(self.t5_encoder.text_encoder)
|
||||
|
||||
@@ -68,15 +70,6 @@ class FluxTextEncoderInvocation(BaseInvocation):
|
||||
|
||||
prompt_embeds = t5_encoder(prompt)
|
||||
|
||||
assert isinstance(prompt_embeds, torch.Tensor)
|
||||
return prompt_embeds
|
||||
|
||||
def _clip_encode(self, context: InvocationContext) -> torch.Tensor:
|
||||
clip_tokenizer_info = context.models.load(self.clip.tokenizer)
|
||||
clip_text_encoder_info = context.models.load(self.clip.text_encoder)
|
||||
|
||||
prompt = [self.prompt]
|
||||
|
||||
with (
|
||||
clip_text_encoder_info as clip_text_encoder,
|
||||
clip_tokenizer_info as clip_tokenizer,
|
||||
@@ -88,5 +81,6 @@ class FluxTextEncoderInvocation(BaseInvocation):
|
||||
|
||||
pooled_prompt_embeds = clip_encoder(prompt)
|
||||
|
||||
assert isinstance(prompt_embeds, torch.Tensor)
|
||||
assert isinstance(pooled_prompt_embeds, torch.Tensor)
|
||||
return pooled_prompt_embeds
|
||||
return prompt_embeds, pooled_prompt_embeds
|
||||
|
||||
@@ -58,7 +58,13 @@ class FluxTextToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
|
||||
@torch.no_grad()
|
||||
def invoke(self, context: InvocationContext) -> ImageOutput:
|
||||
latents = self._run_diffusion(context)
|
||||
# Load the conditioning data.
|
||||
cond_data = context.conditioning.load(self.positive_text_conditioning.conditioning_name)
|
||||
assert len(cond_data.conditionings) == 1
|
||||
flux_conditioning = cond_data.conditionings[0]
|
||||
assert isinstance(flux_conditioning, FLUXConditioningInfo)
|
||||
|
||||
latents = self._run_diffusion(context, flux_conditioning.clip_embeds, flux_conditioning.t5_embeds)
|
||||
image = self._run_vae_decoding(context, latents)
|
||||
image_dto = context.images.save(image=image)
|
||||
return ImageOutput.build(image_dto)
|
||||
@@ -66,19 +72,11 @@ class FluxTextToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
def _run_diffusion(
|
||||
self,
|
||||
context: InvocationContext,
|
||||
clip_embeddings: torch.Tensor,
|
||||
t5_embeddings: torch.Tensor,
|
||||
):
|
||||
inference_dtype = torch.bfloat16
|
||||
|
||||
# Load the conditioning data.
|
||||
cond_data = context.conditioning.load(self.positive_text_conditioning.conditioning_name)
|
||||
assert len(cond_data.conditionings) == 1
|
||||
flux_conditioning = cond_data.conditionings[0]
|
||||
assert isinstance(flux_conditioning, FLUXConditioningInfo)
|
||||
flux_conditioning = flux_conditioning.to(dtype=inference_dtype)
|
||||
t5_embeddings = flux_conditioning.t5_embeds
|
||||
clip_embeddings = flux_conditioning.clip_embeds
|
||||
|
||||
transformer_info = context.models.load(self.transformer.transformer)
|
||||
inference_dtype = torch.bfloat16
|
||||
|
||||
# Prepare input noise.
|
||||
x = get_noise(
|
||||
@@ -90,19 +88,24 @@ class FluxTextToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
seed=self.seed,
|
||||
)
|
||||
|
||||
x, img_ids = prepare_latent_img_patches(x)
|
||||
img, img_ids = prepare_latent_img_patches(x)
|
||||
|
||||
is_schnell = "schnell" in transformer_info.config.config_path
|
||||
|
||||
timesteps = get_schedule(
|
||||
num_steps=self.num_steps,
|
||||
image_seq_len=x.shape[1],
|
||||
image_seq_len=img.shape[1],
|
||||
shift=not is_schnell,
|
||||
)
|
||||
|
||||
bs, t5_seq_len, _ = t5_embeddings.shape
|
||||
txt_ids = torch.zeros(bs, t5_seq_len, 3, dtype=inference_dtype, device=TorchDevice.choose_torch_device())
|
||||
|
||||
# HACK(ryand): Manually empty the cache. Currently we don't check the size of the model before loading it from
|
||||
# disk. Since the transformer model is large (24GB), there's a good chance that it will OOM on 32GB RAM systems
|
||||
# if the cache is not empty.
|
||||
context.models._services.model_manager.load.ram_cache.make_room(24 * 2**30)
|
||||
|
||||
with transformer_info as transformer:
|
||||
assert isinstance(transformer, Flux)
|
||||
|
||||
@@ -137,7 +140,7 @@ class FluxTextToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
|
||||
|
||||
x = denoise(
|
||||
model=transformer,
|
||||
img=x,
|
||||
img=img,
|
||||
img_ids=img_ids,
|
||||
txt=t5_embeddings,
|
||||
txt_ids=txt_ids,
|
||||
|
||||
@@ -157,7 +157,7 @@ class FluxModelLoaderOutput(BaseInvocationOutput):
|
||||
title="Flux Main Model",
|
||||
tags=["model", "flux"],
|
||||
category="model",
|
||||
version="1.0.4",
|
||||
version="1.0.3",
|
||||
classification=Classification.Prototype,
|
||||
)
|
||||
class FluxModelLoaderInvocation(BaseInvocation):
|
||||
@@ -169,35 +169,23 @@ class FluxModelLoaderInvocation(BaseInvocation):
|
||||
input=Input.Direct,
|
||||
)
|
||||
|
||||
t5_encoder_model: ModelIdentifierField = InputField(
|
||||
description=FieldDescriptions.t5_encoder, ui_type=UIType.T5EncoderModel, input=Input.Direct, title="T5 Encoder"
|
||||
)
|
||||
|
||||
clip_embed_model: ModelIdentifierField = InputField(
|
||||
description=FieldDescriptions.clip_embed_model,
|
||||
ui_type=UIType.CLIPEmbedModel,
|
||||
t5_encoder: ModelIdentifierField = InputField(
|
||||
description=FieldDescriptions.t5_encoder,
|
||||
ui_type=UIType.T5EncoderModel,
|
||||
input=Input.Direct,
|
||||
title="CLIP Embed",
|
||||
)
|
||||
|
||||
vae_model: ModelIdentifierField = InputField(
|
||||
description=FieldDescriptions.vae_model, ui_type=UIType.FluxVAEModel, title="VAE"
|
||||
)
|
||||
|
||||
def invoke(self, context: InvocationContext) -> FluxModelLoaderOutput:
|
||||
for key in [self.model.key, self.t5_encoder_model.key, self.clip_embed_model.key, self.vae_model.key]:
|
||||
if not context.models.exists(key):
|
||||
raise ValueError(f"Unknown model: {key}")
|
||||
|
||||
transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
|
||||
vae = self.vae_model.model_copy(update={"submodel_type": SubModelType.VAE})
|
||||
|
||||
tokenizer = self.clip_embed_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
|
||||
clip_encoder = self.clip_embed_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
|
||||
|
||||
tokenizer2 = self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer2})
|
||||
t5_encoder = self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder2})
|
||||
model_key = self.model.key
|
||||
|
||||
if not context.models.exists(model_key):
|
||||
raise ValueError(f"Unknown model: {model_key}")
|
||||
transformer = self._get_model(context, SubModelType.Transformer)
|
||||
tokenizer = self._get_model(context, SubModelType.Tokenizer)
|
||||
tokenizer2 = self._get_model(context, SubModelType.Tokenizer2)
|
||||
clip_encoder = self._get_model(context, SubModelType.TextEncoder)
|
||||
t5_encoder = self._get_model(context, SubModelType.TextEncoder2)
|
||||
vae = self._get_model(context, SubModelType.VAE)
|
||||
transformer_config = context.models.get_config(transformer)
|
||||
assert isinstance(transformer_config, CheckpointConfigBase)
|
||||
|
||||
@@ -209,6 +197,52 @@ class FluxModelLoaderInvocation(BaseInvocation):
|
||||
max_seq_len=max_seq_lengths[transformer_config.config_path],
|
||||
)
|
||||
|
||||
def _get_model(self, context: InvocationContext, submodel: SubModelType) -> ModelIdentifierField:
|
||||
match submodel:
|
||||
case SubModelType.Transformer:
|
||||
return self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
|
||||
case SubModelType.VAE:
|
||||
return self._pull_model_from_mm(
|
||||
context,
|
||||
SubModelType.VAE,
|
||||
"FLUX.1-schnell_ae",
|
||||
ModelType.VAE,
|
||||
BaseModelType.Flux,
|
||||
)
|
||||
case submodel if submodel in [SubModelType.Tokenizer, SubModelType.TextEncoder]:
|
||||
return self._pull_model_from_mm(
|
||||
context,
|
||||
submodel,
|
||||
"clip-vit-large-patch14",
|
||||
ModelType.CLIPEmbed,
|
||||
BaseModelType.Any,
|
||||
)
|
||||
case submodel if submodel in [SubModelType.Tokenizer2, SubModelType.TextEncoder2]:
|
||||
return self._pull_model_from_mm(
|
||||
context,
|
||||
submodel,
|
||||
self.t5_encoder.name,
|
||||
ModelType.T5Encoder,
|
||||
BaseModelType.Any,
|
||||
)
|
||||
case _:
|
||||
raise Exception(f"{submodel.value} is not a supported submodule for a flux model")
|
||||
|
||||
def _pull_model_from_mm(
|
||||
self,
|
||||
context: InvocationContext,
|
||||
submodel: SubModelType,
|
||||
name: str,
|
||||
type: ModelType,
|
||||
base: BaseModelType,
|
||||
):
|
||||
if models := context.models.search_by_attrs(name=name, base=base, type=type):
|
||||
if len(models) != 1:
|
||||
raise Exception(f"Multiple models detected for selected model with name {name}")
|
||||
return ModelIdentifierField.from_config(models[0]).model_copy(update={"submodel_type": submodel})
|
||||
else:
|
||||
raise ValueError(f"Please install the {base}:{type} model named {name} via starter models")
|
||||
|
||||
|
||||
@invocation(
|
||||
"main_model_loader",
|
||||
|
||||
@@ -88,8 +88,7 @@ class QueueItemEventBase(QueueEventBase):
|
||||
|
||||
item_id: int = Field(description="The ID of the queue item")
|
||||
batch_id: str = Field(description="The ID of the queue batch")
|
||||
origin: str | None = Field(default=None, description="The origin of the queue item")
|
||||
destination: str | None = Field(default=None, description="The destination of the queue item")
|
||||
origin: str | None = Field(default=None, description="The origin of the batch")
|
||||
|
||||
|
||||
class InvocationEventBase(QueueItemEventBase):
|
||||
@@ -115,7 +114,6 @@ class InvocationStartedEvent(InvocationEventBase):
|
||||
item_id=queue_item.item_id,
|
||||
batch_id=queue_item.batch_id,
|
||||
origin=queue_item.origin,
|
||||
destination=queue_item.destination,
|
||||
session_id=queue_item.session_id,
|
||||
invocation=invocation,
|
||||
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
|
||||
@@ -150,7 +148,6 @@ class InvocationDenoiseProgressEvent(InvocationEventBase):
|
||||
item_id=queue_item.item_id,
|
||||
batch_id=queue_item.batch_id,
|
||||
origin=queue_item.origin,
|
||||
destination=queue_item.destination,
|
||||
session_id=queue_item.session_id,
|
||||
invocation=invocation,
|
||||
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
|
||||
@@ -189,7 +186,6 @@ class InvocationCompleteEvent(InvocationEventBase):
|
||||
item_id=queue_item.item_id,
|
||||
batch_id=queue_item.batch_id,
|
||||
origin=queue_item.origin,
|
||||
destination=queue_item.destination,
|
||||
session_id=queue_item.session_id,
|
||||
invocation=invocation,
|
||||
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
|
||||
@@ -223,7 +219,6 @@ class InvocationErrorEvent(InvocationEventBase):
|
||||
item_id=queue_item.item_id,
|
||||
batch_id=queue_item.batch_id,
|
||||
origin=queue_item.origin,
|
||||
destination=queue_item.destination,
|
||||
session_id=queue_item.session_id,
|
||||
invocation=invocation,
|
||||
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
|
||||
@@ -262,7 +257,6 @@ class QueueItemStatusChangedEvent(QueueItemEventBase):
|
||||
item_id=queue_item.item_id,
|
||||
batch_id=queue_item.batch_id,
|
||||
origin=queue_item.origin,
|
||||
destination=queue_item.destination,
|
||||
session_id=queue_item.session_id,
|
||||
status=queue_item.status,
|
||||
error_type=queue_item.error_type,
|
||||
|
||||
@@ -77,14 +77,7 @@ BatchDataCollection: TypeAlias = list[list[BatchDatum]]
|
||||
|
||||
class Batch(BaseModel):
|
||||
batch_id: str = Field(default_factory=uuid_string, description="The ID of the batch")
|
||||
origin: str | None = Field(
|
||||
default=None,
|
||||
description="The origin of this queue item. This data is used by the frontend to determine how to handle results.",
|
||||
)
|
||||
destination: str | None = Field(
|
||||
default=None,
|
||||
description="The origin of this queue item. This data is used by the frontend to determine how to handle results",
|
||||
)
|
||||
origin: str | None = Field(default=None, description="The origin of this batch.")
|
||||
data: Optional[BatchDataCollection] = Field(default=None, description="The batch data collection.")
|
||||
graph: Graph = Field(description="The graph to initialize the session with")
|
||||
workflow: Optional[WorkflowWithoutID] = Field(
|
||||
@@ -203,14 +196,7 @@ class SessionQueueItemWithoutGraph(BaseModel):
|
||||
status: QUEUE_ITEM_STATUS = Field(default="pending", description="The status of this queue item")
|
||||
priority: int = Field(default=0, description="The priority of this queue item")
|
||||
batch_id: str = Field(description="The ID of the batch associated with this queue item")
|
||||
origin: str | None = Field(
|
||||
default=None,
|
||||
description="The origin of this queue item. This data is used by the frontend to determine how to handle results.",
|
||||
)
|
||||
destination: str | None = Field(
|
||||
default=None,
|
||||
description="The origin of this queue item. This data is used by the frontend to determine how to handle results",
|
||||
)
|
||||
origin: str | None = Field(default=None, description="The origin of this queue item. ")
|
||||
session_id: str = Field(
|
||||
description="The ID of the session associated with this queue item. The session doesn't exist in graph_executions until the queue item is executed."
|
||||
)
|
||||
@@ -311,7 +297,6 @@ class BatchStatus(BaseModel):
|
||||
queue_id: str = Field(..., description="The ID of the queue")
|
||||
batch_id: str = Field(..., description="The ID of the batch")
|
||||
origin: str | None = Field(..., description="The origin of the batch")
|
||||
destination: str | None = Field(..., description="The destination of the batch")
|
||||
pending: int = Field(..., description="Number of queue items with status 'pending'")
|
||||
in_progress: int = Field(..., description="Number of queue items with status 'in_progress'")
|
||||
completed: int = Field(..., description="Number of queue items with status 'complete'")
|
||||
@@ -458,7 +443,6 @@ class SessionQueueValueToInsert(NamedTuple):
|
||||
priority: int # priority
|
||||
workflow: Optional[str] # workflow json
|
||||
origin: str | None
|
||||
destination: str | None
|
||||
|
||||
|
||||
ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert]
|
||||
@@ -480,7 +464,6 @@ def prepare_values_to_insert(queue_id: str, batch: Batch, priority: int, max_new
|
||||
priority, # priority
|
||||
json.dumps(workflow, default=to_jsonable_python) if workflow else None, # workflow (json)
|
||||
batch.origin, # origin
|
||||
batch.destination, # destination
|
||||
)
|
||||
)
|
||||
return values_to_insert
|
||||
|
||||
@@ -128,8 +128,8 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
|
||||
self.__cursor.executemany(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
values_to_insert,
|
||||
)
|
||||
@@ -579,8 +579,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
session_id,
|
||||
batch_id,
|
||||
queue_id,
|
||||
origin,
|
||||
destination
|
||||
origin
|
||||
FROM session_queue
|
||||
WHERE queue_id = ?
|
||||
"""
|
||||
@@ -660,7 +659,7 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
self.__lock.acquire()
|
||||
self.__cursor.execute(
|
||||
"""--sql
|
||||
SELECT status, count(*), origin, destination
|
||||
SELECT status, count(*), origin
|
||||
FROM session_queue
|
||||
WHERE
|
||||
queue_id = ?
|
||||
@@ -673,7 +672,6 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
total = sum(row[1] for row in result)
|
||||
counts: dict[str, int] = {row[0]: row[1] for row in result}
|
||||
origin = result[0]["origin"] if result else None
|
||||
destination = result[0]["destination"] if result else None
|
||||
except Exception:
|
||||
self.__conn.rollback()
|
||||
raise
|
||||
@@ -683,7 +681,6 @@ class SqliteSessionQueue(SessionQueueBase):
|
||||
return BatchStatus(
|
||||
batch_id=batch_id,
|
||||
origin=origin,
|
||||
destination=destination,
|
||||
queue_id=queue_id,
|
||||
pending=counts.get("pending", 0),
|
||||
in_progress=counts.get("in_progress", 0),
|
||||
|
||||
@@ -10,11 +10,9 @@ class Migration15Callback:
|
||||
def _add_origin_col(self, cursor: sqlite3.Cursor) -> None:
|
||||
"""
|
||||
- Adds `origin` column to the session queue table.
|
||||
- Adds `destination` column to the session queue table.
|
||||
"""
|
||||
|
||||
cursor.execute("ALTER TABLE session_queue ADD COLUMN origin TEXT;")
|
||||
cursor.execute("ALTER TABLE session_queue ADD COLUMN destination TEXT;")
|
||||
|
||||
|
||||
def build_migration_15() -> Migration:
|
||||
@@ -23,7 +21,6 @@ def build_migration_15() -> Migration:
|
||||
|
||||
This migration does the following:
|
||||
- Adds `origin` column to the session queue table.
|
||||
- Adds `destination` column to the session queue table.
|
||||
"""
|
||||
migration_15 = Migration(
|
||||
from_version=14,
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"name": "FLUX Text to Image",
|
||||
"author": "InvokeAI",
|
||||
"description": "A simple text-to-image workflow using FLUX dev or schnell models. Prerequisite model downloads: T5 Encoder, CLIP-L Encoder, and FLUX VAE. Quantized and un-quantized versions can be found in the starter models tab within your Model Manager. We recommend 4 steps for FLUX schnell models and 30 steps for FLUX dev models.",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "text2image, flux",
|
||||
"notes": "Prerequisite model downloads: T5 Encoder, CLIP-L Encoder, and FLUX VAE. Quantized and un-quantized versions can be found in the starter models tab within your Model Manager. We recommend 4 steps for FLUX schnell models and 30 steps for FLUX dev models.",
|
||||
"exposedFields": [
|
||||
{
|
||||
"nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
|
||||
"nodeId": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
|
||||
"fieldName": "model"
|
||||
},
|
||||
{
|
||||
@@ -20,8 +20,8 @@
|
||||
"fieldName": "num_steps"
|
||||
},
|
||||
{
|
||||
"nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
|
||||
"fieldName": "t5_encoder_model"
|
||||
"nodeId": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
|
||||
"fieldName": "t5_encoder"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
@@ -30,12 +30,12 @@
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
|
||||
"id": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
|
||||
"type": "invocation",
|
||||
"data": {
|
||||
"id": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
|
||||
"id": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
|
||||
"type": "flux_model_loader",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.3",
|
||||
"label": "",
|
||||
"notes": "",
|
||||
"isOpen": true,
|
||||
@@ -44,25 +44,31 @@
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"label": ""
|
||||
"label": "Model (Starter Models can be found in Model Manager)",
|
||||
"value": {
|
||||
"key": "f04a7a2f-c74d-4538-8d5e-879a53501662",
|
||||
"hash": "random:4875da7a9508444ffa706f61961c260d0c6729f6181a86b31fad06df1277b850",
|
||||
"name": "FLUX Dev (Quantized)",
|
||||
"base": "flux",
|
||||
"type": "main"
|
||||
}
|
||||
},
|
||||
"t5_encoder_model": {
|
||||
"name": "t5_encoder_model",
|
||||
"label": ""
|
||||
},
|
||||
"clip_embed_model": {
|
||||
"name": "clip_embed_model",
|
||||
"label": ""
|
||||
},
|
||||
"vae_model": {
|
||||
"name": "vae_model",
|
||||
"label": ""
|
||||
"t5_encoder": {
|
||||
"name": "t5_encoder",
|
||||
"label": "T 5 Encoder (Starter Models can be found in Model Manager)",
|
||||
"value": {
|
||||
"key": "20dcd9ec-5fbb-4012-8401-049e707da5e5",
|
||||
"hash": "random:f986be43ff3502169e4adbdcee158afb0e0a65a1edc4cab16ae59963630cfd8f",
|
||||
"name": "t5_bnb_int8_quantized_encoder",
|
||||
"base": "any",
|
||||
"type": "t5_encoder"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"position": {
|
||||
"x": 381.1882713063478,
|
||||
"y": -95.89663532854017
|
||||
"x": 337.09365228062825,
|
||||
"y": 40.63469521079861
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -201,45 +207,45 @@
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90max_seq_len-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_max_seq_len",
|
||||
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33amax_seq_len-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_max_seq_len",
|
||||
"type": "default",
|
||||
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
|
||||
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
|
||||
"target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
|
||||
"sourceHandle": "max_seq_len",
|
||||
"targetHandle": "t5_max_seq_len"
|
||||
},
|
||||
{
|
||||
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90vae-159bdf1b-79e7-4174-b86e-d40e646964c8vae",
|
||||
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33avae-159bdf1b-79e7-4174-b86e-d40e646964c8vae",
|
||||
"type": "default",
|
||||
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
|
||||
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
|
||||
"target": "159bdf1b-79e7-4174-b86e-d40e646964c8",
|
||||
"sourceHandle": "vae",
|
||||
"targetHandle": "vae"
|
||||
},
|
||||
{
|
||||
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90t5_encoder-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_encoder",
|
||||
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33atransformer-159bdf1b-79e7-4174-b86e-d40e646964c8transformer",
|
||||
"type": "default",
|
||||
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
|
||||
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
|
||||
"target": "159bdf1b-79e7-4174-b86e-d40e646964c8",
|
||||
"sourceHandle": "transformer",
|
||||
"targetHandle": "transformer"
|
||||
},
|
||||
{
|
||||
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33at5_encoder-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_encoder",
|
||||
"type": "default",
|
||||
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
|
||||
"target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
|
||||
"sourceHandle": "t5_encoder",
|
||||
"targetHandle": "t5_encoder"
|
||||
},
|
||||
{
|
||||
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90clip-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cclip",
|
||||
"id": "reactflow__edge-4f0207c2-ff40-41fd-b047-ad33fbb1c33aclip-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cclip",
|
||||
"type": "default",
|
||||
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
|
||||
"source": "4f0207c2-ff40-41fd-b047-ad33fbb1c33a",
|
||||
"target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
|
||||
"sourceHandle": "clip",
|
||||
"targetHandle": "clip"
|
||||
},
|
||||
{
|
||||
"id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90transformer-159bdf1b-79e7-4174-b86e-d40e646964c8transformer",
|
||||
"type": "default",
|
||||
"source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
|
||||
"target": "159bdf1b-79e7-4174-b86e-d40e646964c8",
|
||||
"sourceHandle": "transformer",
|
||||
"targetHandle": "transformer"
|
||||
},
|
||||
{
|
||||
"id": "reactflow__edge-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cconditioning-159bdf1b-79e7-4174-b86e-d40e646964c8positive_text_conditioning",
|
||||
"type": "default",
|
||||
|
||||
@@ -111,7 +111,16 @@ def denoise(
|
||||
step_callback: Callable[[], None],
|
||||
guidance: float = 4.0,
|
||||
):
|
||||
# guidance_vec is ignored for schnell.
|
||||
dtype = model.txt_in.bias.dtype
|
||||
|
||||
# TODO(ryand): This shouldn't be necessary if we manage the dtypes properly in the caller.
|
||||
img = img.to(dtype=dtype)
|
||||
img_ids = img_ids.to(dtype=dtype)
|
||||
txt = txt.to(dtype=dtype)
|
||||
txt_ids = txt_ids.to(dtype=dtype)
|
||||
vec = vec.to(dtype=dtype)
|
||||
|
||||
# this is ignored for schnell
|
||||
guidance_vec = torch.full((img.shape[0],), guidance, device=img.device, dtype=img.dtype)
|
||||
for t_curr, t_prev in tqdm(list(zip(timesteps[:-1], timesteps[1:], strict=True))):
|
||||
t_vec = torch.full((img.shape[0],), t_curr, dtype=img.dtype, device=img.device)
|
||||
@@ -159,9 +168,9 @@ def prepare_latent_img_patches(latent_img: torch.Tensor) -> tuple[torch.Tensor,
|
||||
img = repeat(img, "1 ... -> bs ...", bs=bs)
|
||||
|
||||
# Generate patch position ids.
|
||||
img_ids = torch.zeros(h // 2, w // 2, 3, device=img.device, dtype=img.dtype)
|
||||
img_ids[..., 1] = img_ids[..., 1] + torch.arange(h // 2, device=img.device, dtype=img.dtype)[:, None]
|
||||
img_ids[..., 2] = img_ids[..., 2] + torch.arange(w // 2, device=img.device, dtype=img.dtype)[None, :]
|
||||
img_ids = torch.zeros(h // 2, w // 2, 3, device=img.device)
|
||||
img_ids[..., 1] = img_ids[..., 1] + torch.arange(h // 2, device=img.device)[:, None]
|
||||
img_ids[..., 2] = img_ids[..., 2] + torch.arange(w // 2, device=img.device)[None, :]
|
||||
img_ids = repeat(img_ids, "h w c -> b (h w) c", b=bs)
|
||||
|
||||
return img, img_ids
|
||||
|
||||
@@ -72,7 +72,6 @@ class ModelLoader(ModelLoaderBase):
|
||||
pass
|
||||
|
||||
config.path = str(self._get_model_path(config))
|
||||
self._ram_cache.make_room(self.get_size_fs(config, Path(config.path), submodel_type))
|
||||
loaded_model = self._load_model(config, submodel_type)
|
||||
|
||||
self._ram_cache.put(
|
||||
|
||||
@@ -193,6 +193,15 @@ class ModelCacheBase(ABC, Generic[T]):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def exists(
|
||||
self,
|
||||
key: str,
|
||||
submodel_type: Optional[SubModelType] = None,
|
||||
) -> bool:
|
||||
"""Return true if the model identified by key and submodel_type is in the cache."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def cache_size(self) -> int:
|
||||
"""Get the total size of the models currently cached."""
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
# Copyright (c) 2024 Lincoln D. Stein and the InvokeAI Development team
|
||||
# TODO: Add Stalker's proper name to copyright
|
||||
""" """
|
||||
"""
|
||||
Manage a RAM cache of diffusion/transformer models for fast switching.
|
||||
They are moved between GPU VRAM and CPU RAM as necessary. If the cache
|
||||
grows larger than a preset maximum, then the least recently used
|
||||
model will be cleared and (re)loaded from disk when next needed.
|
||||
|
||||
The cache returns context manager generators designed to load the
|
||||
model into the GPU within the context, and unload outside the
|
||||
context. Use like this:
|
||||
|
||||
cache = ModelCache(max_cache_size=7.5)
|
||||
with cache.get_model('runwayml/stable-diffusion-1-5') as SD1,
|
||||
cache.get_model('stabilityai/stable-diffusion-2') as SD2:
|
||||
do_something_in_GPU(SD1,SD2)
|
||||
|
||||
|
||||
"""
|
||||
|
||||
import gc
|
||||
import math
|
||||
@@ -24,64 +40,45 @@ from invokeai.backend.model_manager.load.model_util import calc_model_size_by_da
|
||||
from invokeai.backend.util.devices import TorchDevice
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
|
||||
# Size of a GB in bytes.
|
||||
GB = 2**30
|
||||
# Maximum size of the cache, in gigs
|
||||
# Default is roughly enough to hold three fp16 diffusers models in RAM simultaneously
|
||||
DEFAULT_MAX_CACHE_SIZE = 6.0
|
||||
|
||||
# amount of GPU memory to hold in reserve for use by generations (GB)
|
||||
DEFAULT_MAX_VRAM_CACHE_SIZE = 2.75
|
||||
|
||||
# actual size of a gig
|
||||
GIG = 1073741824
|
||||
|
||||
# Size of a MB in bytes.
|
||||
MB = 2**20
|
||||
|
||||
|
||||
class ModelCache(ModelCacheBase[AnyModel]):
|
||||
"""A cache for managing models in memory.
|
||||
|
||||
The cache is based on two levels of model storage:
|
||||
- execution_device: The device where most models are executed (typically "cuda", "mps", or "cpu").
|
||||
- storage_device: The device where models are offloaded when not in active use (typically "cpu").
|
||||
|
||||
The model cache is based on the following assumptions:
|
||||
- storage_device_mem_size > execution_device_mem_size
|
||||
- disk_to_storage_device_transfer_time >> storage_device_to_execution_device_transfer_time
|
||||
|
||||
A copy of all models in the cache is always kept on the storage_device. A subset of the models also have a copy on
|
||||
the execution_device.
|
||||
|
||||
Models are moved between the storage_device and the execution_device as necessary. Cache size limits are enforced
|
||||
on both the storage_device and the execution_device. The execution_device cache uses a smallest-first offload
|
||||
policy. The storage_device cache uses a least-recently-used (LRU) offload policy.
|
||||
|
||||
Note: Neither of these offload policies has really been compared against alternatives. It's likely that different
|
||||
policies would be better, although the optimal policies are likely heavily dependent on usage patterns and HW
|
||||
configuration.
|
||||
|
||||
The cache returns context manager generators designed to load the model into the execution device (often GPU) within
|
||||
the context, and unload outside the context.
|
||||
|
||||
Example usage:
|
||||
```
|
||||
cache = ModelCache(max_cache_size=7.5, max_vram_cache_size=6.0)
|
||||
with cache.get_model('runwayml/stable-diffusion-1-5') as SD1:
|
||||
do_something_on_gpu(SD1)
|
||||
```
|
||||
"""
|
||||
"""Implementation of ModelCacheBase."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_cache_size: float,
|
||||
max_vram_cache_size: float,
|
||||
max_cache_size: float = DEFAULT_MAX_CACHE_SIZE,
|
||||
max_vram_cache_size: float = DEFAULT_MAX_VRAM_CACHE_SIZE,
|
||||
execution_device: torch.device = torch.device("cuda"),
|
||||
storage_device: torch.device = torch.device("cpu"),
|
||||
precision: torch.dtype = torch.float16,
|
||||
sequential_offload: bool = False,
|
||||
lazy_offloading: bool = True,
|
||||
sha_chunksize: int = 16777216,
|
||||
log_memory_usage: bool = False,
|
||||
logger: Optional[Logger] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the model RAM cache.
|
||||
|
||||
:param max_cache_size: Maximum size of the storage_device cache in GBs.
|
||||
:param max_vram_cache_size: Maximum size of the execution_device cache in GBs.
|
||||
:param max_cache_size: Maximum size of the RAM cache [6.0 GB]
|
||||
:param execution_device: Torch device to load active model into [torch.device('cuda')]
|
||||
:param storage_device: Torch device to save inactive model in [torch.device('cpu')]
|
||||
:param lazy_offloading: Keep model in VRAM until another model needs to be loaded.
|
||||
:param precision: Precision for loaded models [torch.float16]
|
||||
:param lazy_offloading: Keep model in VRAM until another model needs to be loaded
|
||||
:param sequential_offload: Conserve VRAM by loading and unloading each stage of the pipeline sequentially
|
||||
:param log_memory_usage: If True, a memory snapshot will be captured before and after every model cache
|
||||
operation, and the result will be logged (at debug level). There is a time cost to capturing the memory
|
||||
snapshots, so it is recommended to disable this feature unless you are actively inspecting the model cache's
|
||||
@@ -89,6 +86,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
|
||||
"""
|
||||
# allow lazy offloading only when vram cache enabled
|
||||
self._lazy_offloading = lazy_offloading and max_vram_cache_size > 0
|
||||
self._precision: torch.dtype = precision
|
||||
self._max_cache_size: float = max_cache_size
|
||||
self._max_vram_cache_size: float = max_vram_cache_size
|
||||
self._execution_device: torch.device = execution_device
|
||||
@@ -147,6 +145,15 @@ class ModelCache(ModelCacheBase[AnyModel]):
|
||||
total += cache_record.size
|
||||
return total
|
||||
|
||||
def exists(
|
||||
self,
|
||||
key: str,
|
||||
submodel_type: Optional[SubModelType] = None,
|
||||
) -> bool:
|
||||
"""Return true if the model identified by key and submodel_type is in the cache."""
|
||||
key = self._make_cache_key(key, submodel_type)
|
||||
return key in self._cached_models
|
||||
|
||||
def put(
|
||||
self,
|
||||
key: str,
|
||||
@@ -196,7 +203,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
|
||||
# more stats
|
||||
if self.stats:
|
||||
stats_name = stats_name or key
|
||||
self.stats.cache_size = int(self._max_cache_size * GB)
|
||||
self.stats.cache_size = int(self._max_cache_size * GIG)
|
||||
self.stats.high_watermark = max(self.stats.high_watermark, self.cache_size())
|
||||
self.stats.in_cache = len(self._cached_models)
|
||||
self.stats.loaded_model_sizes[stats_name] = max(
|
||||
@@ -224,13 +231,10 @@ class ModelCache(ModelCacheBase[AnyModel]):
|
||||
return model_key
|
||||
|
||||
def offload_unlocked_models(self, size_required: int) -> None:
|
||||
"""Offload models from the execution_device to make room for size_required.
|
||||
|
||||
:param size_required: The amount of space to clear in the execution_device cache, in bytes.
|
||||
"""
|
||||
reserved = self._max_vram_cache_size * GB
|
||||
"""Move any unused models from VRAM."""
|
||||
reserved = self._max_vram_cache_size * GIG
|
||||
vram_in_use = torch.cuda.memory_allocated() + size_required
|
||||
self.logger.debug(f"{(vram_in_use/GB):.2f}GB VRAM needed for models; max allowed={(reserved/GB):.2f}GB")
|
||||
self.logger.debug(f"{(vram_in_use/GIG):.2f}GB VRAM needed for models; max allowed={(reserved/GIG):.2f}GB")
|
||||
for _, cache_entry in sorted(self._cached_models.items(), key=lambda x: x[1].size):
|
||||
if vram_in_use <= reserved:
|
||||
break
|
||||
@@ -241,7 +245,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
|
||||
cache_entry.loaded = False
|
||||
vram_in_use = torch.cuda.memory_allocated() + size_required
|
||||
self.logger.debug(
|
||||
f"Removing {cache_entry.key} from VRAM to free {(cache_entry.size/GB):.2f}GB; vram free = {(torch.cuda.memory_allocated()/GB):.2f}GB"
|
||||
f"Removing {cache_entry.key} from VRAM to free {(cache_entry.size/GIG):.2f}GB; vram free = {(torch.cuda.memory_allocated()/GIG):.2f}GB"
|
||||
)
|
||||
|
||||
TorchDevice.empty_cache()
|
||||
@@ -299,7 +303,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
|
||||
self.logger.debug(
|
||||
f"Moved model '{cache_entry.key}' from {source_device} to"
|
||||
f" {target_device} in {(end_model_to_time-start_model_to_time):.2f}s."
|
||||
f"Estimated model size: {(cache_entry.size/GB):.3f} GB."
|
||||
f"Estimated model size: {(cache_entry.size/GIG):.3f} GB."
|
||||
f"{get_pretty_snapshot_diff(snapshot_before, snapshot_after)}"
|
||||
)
|
||||
|
||||
@@ -322,14 +326,14 @@ class ModelCache(ModelCacheBase[AnyModel]):
|
||||
f"Moving model '{cache_entry.key}' from {source_device} to"
|
||||
f" {target_device} caused an unexpected change in VRAM usage. The model's"
|
||||
" estimated size may be incorrect. Estimated model size:"
|
||||
f" {(cache_entry.size/GB):.3f} GB.\n"
|
||||
f" {(cache_entry.size/GIG):.3f} GB.\n"
|
||||
f"{get_pretty_snapshot_diff(snapshot_before, snapshot_after)}"
|
||||
)
|
||||
|
||||
def print_cuda_stats(self) -> None:
|
||||
"""Log CUDA diagnostics."""
|
||||
vram = "%4.2fG" % (torch.cuda.memory_allocated() / GB)
|
||||
ram = "%4.2fG" % (self.cache_size() / GB)
|
||||
vram = "%4.2fG" % (torch.cuda.memory_allocated() / GIG)
|
||||
ram = "%4.2fG" % (self.cache_size() / GIG)
|
||||
|
||||
in_ram_models = 0
|
||||
in_vram_models = 0
|
||||
@@ -349,20 +353,17 @@ class ModelCache(ModelCacheBase[AnyModel]):
|
||||
)
|
||||
|
||||
def make_room(self, size: int) -> None:
|
||||
"""Make enough room in the cache to accommodate a new model of indicated size.
|
||||
|
||||
Note: This function deletes all of the cache's internal references to a model in order to free it. If there are
|
||||
external references to the model, there's nothing that the cache can do about it, and those models will not be
|
||||
garbage-collected.
|
||||
"""
|
||||
"""Make enough room in the cache to accommodate a new model of indicated size."""
|
||||
# calculate how much memory this model will require
|
||||
# multiplier = 2 if self.precision==torch.float32 else 1
|
||||
bytes_needed = size
|
||||
maximum_size = self.max_cache_size * GB # stored in GB, convert to bytes
|
||||
maximum_size = self.max_cache_size * GIG # stored in GB, convert to bytes
|
||||
current_size = self.cache_size()
|
||||
|
||||
if current_size + bytes_needed > maximum_size:
|
||||
self.logger.debug(
|
||||
f"Max cache size exceeded: {(current_size/GB):.2f}/{self.max_cache_size:.2f} GB, need an additional"
|
||||
f" {(bytes_needed/GB):.2f} GB"
|
||||
f"Max cache size exceeded: {(current_size/GIG):.2f}/{self.max_cache_size:.2f} GB, need an additional"
|
||||
f" {(bytes_needed/GIG):.2f} GB"
|
||||
)
|
||||
|
||||
self.logger.debug(f"Before making_room: cached_models={len(self._cached_models)}")
|
||||
@@ -379,7 +380,7 @@ class ModelCache(ModelCacheBase[AnyModel]):
|
||||
|
||||
if not cache_entry.locked:
|
||||
self.logger.debug(
|
||||
f"Removing {model_key} from RAM cache to free at least {(size/GB):.2f} GB (-{(cache_entry.size/GB):.2f} GB)"
|
||||
f"Removing {model_key} from RAM cache to free at least {(size/GIG):.2f} GB (-{(cache_entry.size/GIG):.2f} GB)"
|
||||
)
|
||||
current_size -= cache_entry.size
|
||||
models_cleared += 1
|
||||
|
||||
@@ -54,10 +54,8 @@ class InvokeLinear8bitLt(bnb.nn.Linear8bitLt):
|
||||
|
||||
# See `bnb.nn.Linear8bitLt._save_to_state_dict()` for the serialization logic of SCB and weight_format.
|
||||
scb = state_dict.pop(prefix + "SCB", None)
|
||||
|
||||
# Currently, we only support weight_format=0.
|
||||
weight_format = state_dict.pop(prefix + "weight_format", None)
|
||||
assert weight_format == 0
|
||||
# weight_format is unused, but we pop it so we can validate that there are no unexpected keys.
|
||||
_weight_format = state_dict.pop(prefix + "weight_format", None)
|
||||
|
||||
# TODO(ryand): Technically, we should be using `strict`, `missing_keys`, `unexpected_keys`, and `error_msgs`
|
||||
# rather than raising an exception to correctly implement this API.
|
||||
@@ -91,14 +89,6 @@ class InvokeLinear8bitLt(bnb.nn.Linear8bitLt):
|
||||
)
|
||||
self.bias = bias if bias is None else torch.nn.Parameter(bias)
|
||||
|
||||
# Reset the state. The persisted fields are based on the initialization behaviour in
|
||||
# `bnb.nn.Linear8bitLt.__init__()`.
|
||||
new_state = bnb.MatmulLtState()
|
||||
new_state.threshold = self.state.threshold
|
||||
new_state.has_fp16_weights = False
|
||||
new_state.use_pool = self.state.use_pool
|
||||
self.state = new_state
|
||||
|
||||
|
||||
def _convert_linear_layers_to_llm_8bit(
|
||||
module: torch.nn.Module, ignore_modules: set[str], outlier_threshold: float, prefix: str = ""
|
||||
|
||||
@@ -43,11 +43,6 @@ class FLUXConditioningInfo:
|
||||
clip_embeds: torch.Tensor
|
||||
t5_embeds: torch.Tensor
|
||||
|
||||
def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None):
|
||||
self.clip_embeds = self.clip_embeds.to(device=device, dtype=dtype)
|
||||
self.t5_embeds = self.t5_embeds.to(device=device, dtype=dtype)
|
||||
return self
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConditioningFieldData:
|
||||
|
||||
@@ -3,9 +3,10 @@ Initialization file for invokeai.backend.util
|
||||
"""
|
||||
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
from invokeai.backend.util.util import Chdir, directory_size
|
||||
from invokeai.backend.util.util import GIG, Chdir, directory_size
|
||||
|
||||
__all__ = [
|
||||
"GIG",
|
||||
"directory_size",
|
||||
"Chdir",
|
||||
"InvokeAILogger",
|
||||
|
||||
@@ -7,6 +7,9 @@ from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# actual size of a gig
|
||||
GIG = 1073741824
|
||||
|
||||
|
||||
def slugify(value: str, allow_unicode: bool = False) -> str:
|
||||
"""
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -164,10 +164,10 @@
|
||||
"alpha": "Alpha",
|
||||
"selected": "Selected",
|
||||
"tab": "Tab",
|
||||
"view": "View",
|
||||
"viewDesc": "Review images in a large gallery view",
|
||||
"edit": "Edit",
|
||||
"editDesc": "Edit on the Canvas",
|
||||
"viewing": "Viewing",
|
||||
"viewingDesc": "Review images in a large gallery view",
|
||||
"editing": "Editing",
|
||||
"editingDesc": "Edit on the Control Layers canvas",
|
||||
"comparing": "Comparing",
|
||||
"comparingDesc": "Comparing two images",
|
||||
"enabled": "Enabled",
|
||||
@@ -328,13 +328,9 @@
|
||||
"completedIn": "Completed in",
|
||||
"batch": "Batch",
|
||||
"origin": "Origin",
|
||||
"destination": "Destination",
|
||||
"upscaling": "Upscaling",
|
||||
"canvas": "Canvas",
|
||||
"generation": "Generation",
|
||||
"workflows": "Workflows",
|
||||
"other": "Other",
|
||||
"gallery": "Gallery",
|
||||
"originCanvas": "Canvas",
|
||||
"originWorkflows": "Workflows",
|
||||
"originOther": "Other",
|
||||
"batchFieldValues": "Batch Field Values",
|
||||
"item": "Item",
|
||||
"session": "Session",
|
||||
@@ -706,8 +702,6 @@
|
||||
"availableModels": "Available Models",
|
||||
"baseModel": "Base Model",
|
||||
"cancel": "Cancel",
|
||||
"clipEmbed": "CLIP Embed",
|
||||
"clipVision": "CLIP Vision",
|
||||
"config": "Config",
|
||||
"convert": "Convert",
|
||||
"convertingModelBegin": "Converting Model. Please wait.",
|
||||
@@ -795,7 +789,6 @@
|
||||
"settings": "Settings",
|
||||
"simpleModelPlaceholder": "URL or path to a local file or diffusers folder",
|
||||
"source": "Source",
|
||||
"spandrelImageToImage": "Image to Image (Spandrel)",
|
||||
"starterModels": "Starter Models",
|
||||
"starterModelsInModelManager": "Starter Models can be found in Model Manager",
|
||||
"syncModels": "Sync Models",
|
||||
@@ -804,7 +797,6 @@
|
||||
"loraTriggerPhrases": "LoRA Trigger Phrases",
|
||||
"mainModelTriggerPhrases": "Main Model Trigger Phrases",
|
||||
"typePhraseHere": "Type phrase here",
|
||||
"t5Encoder": "T5 Encoder",
|
||||
"upcastAttention": "Upcast Attention",
|
||||
"uploadImage": "Upload Image",
|
||||
"urlOrLocalPath": "URL or Local Path",
|
||||
@@ -1654,14 +1646,6 @@
|
||||
"storeNotInitialized": "Store is not initialized"
|
||||
},
|
||||
"controlLayers": {
|
||||
"bookmark": "Bookmark for Quick Switch",
|
||||
"removeBookmark": "Remove Bookmark",
|
||||
"saveBboxToGallery": "Save Bbox To Gallery",
|
||||
"savedToGalleryOk": "Saved to Gallery",
|
||||
"savedToGalleryError": "Error saving to gallery",
|
||||
"mergeVisible": "Merge Visible",
|
||||
"mergeVisibleOk": "Merged visible layers",
|
||||
"mergeVisibleError": "Error merging visible layers",
|
||||
"clearHistory": "Clear History",
|
||||
"generateMode": "Generate",
|
||||
"generateModeDesc": "Create individual images. Generated images are added directly to the gallery.",
|
||||
@@ -1691,47 +1675,35 @@
|
||||
"deletePrompt": "Delete Prompt",
|
||||
"resetRegion": "Reset Region",
|
||||
"debugLayers": "Debug Layers",
|
||||
"showHUD": "Show HUD",
|
||||
"rectangle": "Rectangle",
|
||||
"maskFill": "Mask Fill",
|
||||
"addPositivePrompt": "Add $t(common.positivePrompt)",
|
||||
"addNegativePrompt": "Add $t(common.negativePrompt)",
|
||||
"addIPAdapter": "Add $t(common.ipAdapter)",
|
||||
"addRasterLayer": "Add $t(controlLayers.rasterLayer)",
|
||||
"addControlLayer": "Add $t(controlLayers.controlLayer)",
|
||||
"addInpaintMask": "Add $t(controlLayers.inpaintMask)",
|
||||
"addRegionalGuidance": "Add $t(controlLayers.regionalGuidance)",
|
||||
"regionalGuidanceLayer": "$t(controlLayers.regionalGuidance) $t(unifiedCanvas.layer)",
|
||||
"raster": "Raster",
|
||||
"rasterLayer": "Raster Layer",
|
||||
"controlLayer": "Control Layer",
|
||||
"inpaintMask": "Inpaint Mask",
|
||||
"regionalGuidance": "Regional Guidance",
|
||||
"ipAdapter": "IP Adapter",
|
||||
"sendToGallery": "Send To Gallery",
|
||||
"sendToGalleryDesc": "Generations will be sent to the gallery.",
|
||||
"sendToCanvas": "Send To Canvas",
|
||||
"sendToCanvasDesc": "Generations will be staged onto the canvas.",
|
||||
"rasterLayer_withCount_one": "$t(controlLayers.rasterLayer)",
|
||||
"controlLayer_withCount_one": "$t(controlLayers.controlLayer)",
|
||||
"inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)",
|
||||
"regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)",
|
||||
"ipAdapter_withCount_one": "$t(controlLayers.ipAdapter)",
|
||||
"rasterLayer_withCount_other": "Raster Layers",
|
||||
"controlLayer_withCount_other": "Control Layers",
|
||||
"inpaintMask_withCount_other": "Inpaint Masks",
|
||||
"regionalGuidance_withCount_other": "Regional Guidance",
|
||||
"ipAdapter_withCount_other": "IP Adapters",
|
||||
"rasterLayer_one": "Raster Layer",
|
||||
"controlLayer_one": "Control Layer",
|
||||
"inpaintMask_one": "Inpaint Mask",
|
||||
"regionalGuidance_one": "Regional Guidance",
|
||||
"ipAdapter_one": "IP Adapter",
|
||||
"rasterLayer_other": "Raster Layers",
|
||||
"controlLayer_other": "Control Layers",
|
||||
"inpaintMask_other": "Inpaint Masks",
|
||||
"regionalGuidance_other": "Regional Guidance",
|
||||
"ipAdapter_other": "IP Adapters",
|
||||
"opacity": "Opacity",
|
||||
"regionalGuidance_withCount_hidden": "Regional Guidance ({{count}} hidden)",
|
||||
"controlAdapters_withCount_hidden": "Control Adapters ({{count}} hidden)",
|
||||
"controlLayers_withCount_hidden": "Control Layers ({{count}} hidden)",
|
||||
"rasterLayers_withCount_hidden": "Raster Layers ({{count}} hidden)",
|
||||
"globalIPAdapters_withCount_hidden": "Global IP Adapters ({{count}} hidden)",
|
||||
"ipAdapters_withCount_hidden": "IP Adapters ({{count}} hidden)",
|
||||
"inpaintMasks_withCount_hidden": "Inpaint Masks ({{count}} hidden)",
|
||||
"regionalGuidance_withCount_visible": "Regional Guidance ({{count}})",
|
||||
"controlAdapters_withCount_visible": "Control Adapters ({{count}})",
|
||||
"controlLayers_withCount_visible": "Control Layers ({{count}})",
|
||||
"rasterLayers_withCount_visible": "Raster Layers ({{count}})",
|
||||
"globalIPAdapters_withCount_visible": "Global IP Adapters ({{count}})",
|
||||
"ipAdapters_withCount_visible": "IP Adapters ({{count}})",
|
||||
"inpaintMasks_withCount_visible": "Inpaint Masks ({{count}})",
|
||||
"globalControlAdapter": "Global $t(controlnet.controlAdapter_one)",
|
||||
"globalControlAdapterLayer": "Global $t(controlnet.controlAdapter_one) $t(unifiedCanvas.layer)",
|
||||
@@ -1744,8 +1716,8 @@
|
||||
"clearProcessor": "Clear Processor",
|
||||
"resetProcessor": "Reset Processor to Defaults",
|
||||
"noLayersAdded": "No Layers Added",
|
||||
"layer_one": "Layer",
|
||||
"layer_other": "Layers",
|
||||
"layers_one": "Layer",
|
||||
"layers_other": "Layers",
|
||||
"objects_zero": "empty",
|
||||
"objects_one": "{{count}} object",
|
||||
"objects_other": "{{count}} objects",
|
||||
@@ -1765,7 +1737,6 @@
|
||||
"flipHorizontal": "Flip Horizontal",
|
||||
"flipVertical": "Flip Vertical",
|
||||
"fill": {
|
||||
"fillColor": "Fill Color",
|
||||
"fillStyle": "Fill Style",
|
||||
"solid": "Solid",
|
||||
"grid": "Grid",
|
||||
@@ -1781,6 +1752,7 @@
|
||||
"bbox": "Bbox",
|
||||
"move": "Move",
|
||||
"view": "View",
|
||||
"transform": "Transform",
|
||||
"colorPicker": "Color Picker"
|
||||
},
|
||||
"filter": {
|
||||
@@ -1790,13 +1762,6 @@
|
||||
"preview": "Preview",
|
||||
"apply": "Apply",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"transform": {
|
||||
"transform": "Transform",
|
||||
"fitToBbox": "Fit to Bbox",
|
||||
"reset": "Reset",
|
||||
"apply": "Apply",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"upscaling": {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicP
|
||||
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
|
||||
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
|
||||
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
|
||||
import { activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
|
||||
import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal';
|
||||
import SettingsModal from 'features/system/components/SettingsModal/SettingsModal';
|
||||
import { configChanged } from 'features/system/store/configSlice';
|
||||
@@ -44,17 +43,10 @@ interface Props {
|
||||
action: 'sendToImg2Img' | 'sendToCanvas' | 'useAllParameters';
|
||||
};
|
||||
selectedWorkflowId?: string;
|
||||
selectedStylePresetId?: string;
|
||||
destination?: TabName;
|
||||
destination?: TabName | undefined;
|
||||
}
|
||||
|
||||
const App = ({
|
||||
config = DEFAULT_CONFIG,
|
||||
selectedImage,
|
||||
selectedWorkflowId,
|
||||
selectedStylePresetId,
|
||||
destination,
|
||||
}: Props) => {
|
||||
const App = ({ config = DEFAULT_CONFIG, selectedImage, selectedWorkflowId, destination }: Props) => {
|
||||
const language = useAppSelector(selectLanguage);
|
||||
const logger = useLogger('system');
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -93,12 +85,6 @@ const App = ({
|
||||
}
|
||||
}, [selectedWorkflowId, getAndLoadWorkflow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStylePresetId) {
|
||||
dispatch(activeStylePresetIdChanged(selectedStylePresetId));
|
||||
}
|
||||
}, [dispatch, selectedStylePresetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (destination) {
|
||||
dispatch(setActiveTab(destination));
|
||||
|
||||
@@ -45,7 +45,6 @@ interface Props extends PropsWithChildren {
|
||||
action: 'sendToImg2Img' | 'sendToCanvas' | 'useAllParameters';
|
||||
};
|
||||
selectedWorkflowId?: string;
|
||||
selectedStylePresetId?: string;
|
||||
destination?: TabName;
|
||||
customStarUi?: CustomStarUi;
|
||||
socketOptions?: Partial<ManagerOptions & SocketOptions>;
|
||||
@@ -67,7 +66,6 @@ const InvokeAIUI = ({
|
||||
queueId,
|
||||
selectedImage,
|
||||
selectedWorkflowId,
|
||||
selectedStylePresetId,
|
||||
destination,
|
||||
customStarUi,
|
||||
socketOptions,
|
||||
@@ -229,7 +227,6 @@ const InvokeAIUI = ({
|
||||
config={config}
|
||||
selectedImage={selectedImage}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
selectedStylePresetId={selectedStylePresetId}
|
||||
destination={destination}
|
||||
/>
|
||||
</AppDndContext>
|
||||
|
||||
@@ -68,7 +68,7 @@ export const addStagingListeners = (startAppListening: AppStartListening) => {
|
||||
objects: [imageObject],
|
||||
};
|
||||
|
||||
api.dispatch(rasterLayerAdded({ overrides, isSelected: false }));
|
||||
api.dispatch(rasterLayerAdded({ overrides, isSelected: true }));
|
||||
api.dispatch(sessionStagingAreaReset());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
|
||||
|
||||
let didStartStaging = false;
|
||||
|
||||
if (!state.canvasSession.isStaging && state.canvasSession.sendToCanvas) {
|
||||
if (!state.canvasSession.isStaging && state.canvasSession.mode === 'compose') {
|
||||
dispatch(sessionStartedStaging());
|
||||
didStartStaging = true;
|
||||
}
|
||||
@@ -70,11 +70,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
|
||||
|
||||
const { g, noise, posCond } = buildGraphResult.value;
|
||||
|
||||
const destination = state.canvasSession.sendToCanvas ? 'canvas' : 'gallery';
|
||||
|
||||
const prepareBatchResult = withResult(() =>
|
||||
prepareLinearUIBatch(state, g, prepend, noise, posCond, 'generation', destination)
|
||||
);
|
||||
const prepareBatchResult = withResult(() => prepareLinearUIBatch(state, g, prepend, noise, posCond));
|
||||
|
||||
if (isErr(prepareBatchResult)) {
|
||||
log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch');
|
||||
|
||||
@@ -32,7 +32,6 @@ export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) =
|
||||
workflow: builtWorkflow,
|
||||
runs: state.params.iterations,
|
||||
origin: 'workflows',
|
||||
destination: 'gallery',
|
||||
},
|
||||
prepend: action.payload.prepend,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening)
|
||||
|
||||
const { g, noise, posCond } = await buildMultidiffusionUpscaleGraph(state);
|
||||
|
||||
const batchConfig = prepareLinearUIBatch(state, g, prepend, noise, posCond, 'upscaling', 'gallery');
|
||||
const batchConfig = prepareLinearUIBatch(state, g, prepend, noise, posCond);
|
||||
|
||||
const req = dispatch(
|
||||
queueApi.endpoints.enqueueBatch.initiate(batchConfig, {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, IconButton, Tooltip, useToken } from '@invoke-ai/ui-library';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
type IconSwitchProps = {
|
||||
isChecked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
iconChecked: ReactElement;
|
||||
tooltipChecked?: ReactNode;
|
||||
iconUnchecked: ReactElement;
|
||||
tooltipUnchecked?: ReactNode;
|
||||
ariaLabel: string;
|
||||
};
|
||||
|
||||
const getSx = (padding: string | number): SystemStyleObject => ({
|
||||
transition: 'left 0.1s ease-in-out, transform 0.1s ease-in-out',
|
||||
'&[data-checked="true"]': {
|
||||
left: `calc(100% - ${padding})`,
|
||||
transform: 'translateX(-100%)',
|
||||
},
|
||||
'&[data-checked="false"]': {
|
||||
left: padding,
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
});
|
||||
|
||||
export const IconSwitch = memo(
|
||||
({
|
||||
isChecked,
|
||||
onChange,
|
||||
iconChecked,
|
||||
tooltipChecked,
|
||||
iconUnchecked,
|
||||
tooltipUnchecked,
|
||||
ariaLabel,
|
||||
}: IconSwitchProps) => {
|
||||
const onUncheck = useCallback(() => {
|
||||
onChange(false);
|
||||
}, [onChange]);
|
||||
const onCheck = useCallback(() => {
|
||||
onChange(true);
|
||||
}, [onChange]);
|
||||
|
||||
const gap = useToken('space', 1.5);
|
||||
const sx = useMemo(() => getSx(gap), [gap]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
position="relative"
|
||||
bg="base.800"
|
||||
borderRadius="base"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
h="full"
|
||||
p={gap}
|
||||
gap={gap}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
borderRadius="base"
|
||||
bg="invokeBlue.400"
|
||||
w={12}
|
||||
top={gap}
|
||||
bottom={gap}
|
||||
data-checked={isChecked}
|
||||
sx={sx}
|
||||
/>
|
||||
<Tooltip hasArrow label={tooltipUnchecked}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
fontSize={16}
|
||||
icon={iconUnchecked}
|
||||
onClick={onUncheck}
|
||||
variant={!isChecked ? 'solid' : 'ghost'}
|
||||
colorScheme={!isChecked ? 'invokeBlue' : 'base'}
|
||||
aria-label={ariaLabel}
|
||||
data-checked={!isChecked}
|
||||
w={12}
|
||||
alignSelf="stretch"
|
||||
h="auto"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip hasArrow label={tooltipChecked}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
fontSize={16}
|
||||
icon={iconChecked}
|
||||
onClick={onCheck}
|
||||
variant={isChecked ? 'solid' : 'ghost'}
|
||||
colorScheme={isChecked ? 'invokeBlue' : 'base'}
|
||||
aria-label={ariaLabel}
|
||||
data-checked={isChecked}
|
||||
w={12}
|
||||
alignSelf="stretch"
|
||||
h="auto"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
IconSwitch.displayName = 'IconSwitch';
|
||||
@@ -1,74 +1,52 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { WritableAtom } from 'nanostores';
|
||||
import { atom } from 'nanostores';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
type UseBoolean = {
|
||||
isTrue: boolean;
|
||||
setTrue: () => void;
|
||||
setFalse: () => void;
|
||||
set: (value: boolean) => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a hook to manage a boolean state. The boolean is stored in a nanostores atom.
|
||||
* Returns a tuple containing the hook and the atom. Use this for global boolean state.
|
||||
* @param initialValue Initial value of the boolean
|
||||
*/
|
||||
export const buildUseBoolean = (initialValue: boolean): [() => UseBoolean, WritableAtom<boolean>] => {
|
||||
const $boolean = atom(initialValue);
|
||||
|
||||
const setTrue = () => {
|
||||
$boolean.set(true);
|
||||
};
|
||||
const setFalse = () => {
|
||||
$boolean.set(false);
|
||||
};
|
||||
const set = (value: boolean) => {
|
||||
$boolean.set(value);
|
||||
};
|
||||
const toggle = () => {
|
||||
$boolean.set(!$boolean.get());
|
||||
};
|
||||
|
||||
const useBoolean = () => {
|
||||
const isTrue = useStore($boolean);
|
||||
|
||||
return {
|
||||
isTrue,
|
||||
setTrue,
|
||||
setFalse,
|
||||
set,
|
||||
toggle,
|
||||
};
|
||||
};
|
||||
|
||||
return [useBoolean, $boolean] as const;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage a boolean state. Use this for a local boolean state.
|
||||
* @param initialValue Initial value of the boolean
|
||||
*/
|
||||
export const useBoolean = (initialValue: boolean) => {
|
||||
const [isTrue, set] = useState(initialValue);
|
||||
const setTrue = useCallback(() => set(true), []);
|
||||
const setFalse = useCallback(() => set(false), []);
|
||||
const toggle = useCallback(() => set((v) => !v), []);
|
||||
|
||||
const setTrue = useCallback(() => {
|
||||
set(true);
|
||||
}, [set]);
|
||||
const setFalse = useCallback(() => {
|
||||
set(false);
|
||||
}, [set]);
|
||||
const toggle = useCallback(() => {
|
||||
set((val) => !val);
|
||||
}, [set]);
|
||||
const api = useMemo(
|
||||
() => ({
|
||||
isTrue,
|
||||
set,
|
||||
setTrue,
|
||||
setFalse,
|
||||
toggle,
|
||||
}),
|
||||
[isTrue, set, setTrue, setFalse, toggle]
|
||||
);
|
||||
|
||||
return {
|
||||
isTrue,
|
||||
setTrue,
|
||||
setFalse,
|
||||
set,
|
||||
toggle,
|
||||
return api;
|
||||
};
|
||||
|
||||
export const buildUseBoolean = ($boolean: WritableAtom<boolean>) => {
|
||||
return () => {
|
||||
const setTrue = useCallback(() => {
|
||||
$boolean.set(true);
|
||||
}, []);
|
||||
const setFalse = useCallback(() => {
|
||||
$boolean.set(false);
|
||||
}, []);
|
||||
const set = useCallback((value: boolean) => {
|
||||
$boolean.set(value);
|
||||
}, []);
|
||||
const toggle = useCallback(() => {
|
||||
$boolean.set(!$boolean.get());
|
||||
}, []);
|
||||
|
||||
const api = useMemo(
|
||||
() => ({
|
||||
setTrue,
|
||||
setFalse,
|
||||
set,
|
||||
toggle,
|
||||
$boolean,
|
||||
}),
|
||||
[set, setFalse, setTrue, toggle]
|
||||
);
|
||||
|
||||
return api;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { addScope, removeScope, setScopes } from 'common/hooks/interactionScopes';
|
||||
import { useClearQueue } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
|
||||
import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem';
|
||||
import { useClearQueue } from 'features/queue/hooks/useClearQueue';
|
||||
import { useQueueBack } from 'features/queue/hooks/useQueueBack';
|
||||
import { useQueueFront } from 'features/queue/hooks/useQueueFront';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
|
||||
@@ -128,7 +128,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
|
||||
canvas.controlLayers.entities
|
||||
.filter((controlLayer) => controlLayer.isEnabled)
|
||||
.forEach((controlLayer, i) => {
|
||||
const layerLiteral = i18n.t('controlLayers.layer_one');
|
||||
const layerLiteral = i18n.t('controlLayers.layers_one');
|
||||
const layerNumber = i + 1;
|
||||
const layerType = i18n.t(LAYER_TYPE_TO_TKEY['control_layer']);
|
||||
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
|
||||
@@ -158,7 +158,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
|
||||
canvas.ipAdapters.entities
|
||||
.filter((entity) => entity.isEnabled)
|
||||
.forEach((entity, i) => {
|
||||
const layerLiteral = i18n.t('controlLayers.layer_one');
|
||||
const layerLiteral = i18n.t('controlLayers.layers_one');
|
||||
const layerNumber = i + 1;
|
||||
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]);
|
||||
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
|
||||
@@ -186,7 +186,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
|
||||
canvas.regions.entities
|
||||
.filter((entity) => entity.isEnabled)
|
||||
.forEach((entity, i) => {
|
||||
const layerLiteral = i18n.t('controlLayers.layer_one');
|
||||
const layerLiteral = i18n.t('controlLayers.layers_one');
|
||||
const layerNumber = i + 1;
|
||||
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]);
|
||||
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
|
||||
@@ -223,7 +223,7 @@ const createSelector = (templates: Templates, isConnected: boolean) =>
|
||||
canvas.rasterLayers.entities
|
||||
.filter((entity) => entity.isEnabled)
|
||||
.forEach((entity, i) => {
|
||||
const layerLiteral = i18n.t('controlLayers.layer_one');
|
||||
const layerLiteral = i18n.t('controlLayers.layers_one');
|
||||
const layerNumber = i + 1;
|
||||
const layerType = i18n.t(LAYER_TYPE_TO_TKEY[entity.type]);
|
||||
const prefix = `${layerLiteral} #${layerNumber} (${layerType})`;
|
||||
|
||||
@@ -31,22 +31,22 @@ export const CanvasAddEntityButtons = memo(() => {
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" w="full" h="full" alignItems="center">
|
||||
<ButtonGroup position="relative" orientation="vertical" isAttached={false} top="20%">
|
||||
<Flex flexDir="column" w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<ButtonGroup orientation="vertical" isAttached={false}>
|
||||
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addInpaintMask}>
|
||||
{t('controlLayers.inpaintMask')}
|
||||
{t('controlLayers.inpaintMask', { count: 1 })}
|
||||
</Button>
|
||||
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addRegionalGuidance}>
|
||||
{t('controlLayers.regionalGuidance')}
|
||||
{t('controlLayers.regionalGuidance', { count: 1 })}
|
||||
</Button>
|
||||
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addRasterLayer}>
|
||||
{t('controlLayers.rasterLayer')}
|
||||
{t('controlLayers.rasterLayer', { count: 1 })}
|
||||
</Button>
|
||||
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addControlLayer}>
|
||||
{t('controlLayers.controlLayer')}
|
||||
{t('controlLayers.controlLayer', { count: 1 })}
|
||||
</Button>
|
||||
<Button variant="ghost" justifyContent="flex-start" leftIcon={<PiPlusBold />} onClick={addIPAdapter}>
|
||||
{t('controlLayers.globalIPAdapter')}
|
||||
{t('controlLayers.ipAdapter', { count: 1 })}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MenuItem } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useDefaultIPAdapter } from 'features/controlLayers/hooks/useLayerControlAdapter';
|
||||
import {
|
||||
controlLayerAdded,
|
||||
inpaintMaskAdded,
|
||||
@@ -15,7 +14,6 @@ import { PiPlusBold } from 'react-icons/pi';
|
||||
export const CanvasEntityListMenuItems = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const defaultIPAdapter = useDefaultIPAdapter();
|
||||
const addInpaintMask = useCallback(() => {
|
||||
dispatch(inpaintMaskAdded({ isSelected: true }));
|
||||
}, [dispatch]);
|
||||
@@ -29,26 +27,25 @@ export const CanvasEntityListMenuItems = memo(() => {
|
||||
dispatch(controlLayerAdded({ isSelected: true }));
|
||||
}, [dispatch]);
|
||||
const addIPAdapter = useCallback(() => {
|
||||
const overrides = { ipAdapter: defaultIPAdapter };
|
||||
dispatch(ipaAdded({ isSelected: true, overrides }));
|
||||
}, [defaultIPAdapter, dispatch]);
|
||||
dispatch(ipaAdded({ isSelected: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<PiPlusBold />} onClick={addInpaintMask}>
|
||||
{t('controlLayers.inpaintMask')}
|
||||
{t('controlLayers.inpaintMask', { count: 1 })}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiPlusBold />} onClick={addRegionalGuidance}>
|
||||
{t('controlLayers.regionalGuidance')}
|
||||
{t('controlLayers.regionalGuidance', { count: 1 })}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiPlusBold />} onClick={addRasterLayer}>
|
||||
{t('controlLayers.rasterLayer')}
|
||||
{t('controlLayers.rasterLayer', { count: 1 })}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiPlusBold />} onClick={addControlLayer}>
|
||||
{t('controlLayers.controlLayer')}
|
||||
{t('controlLayers.controlLayer', { count: 1 })}
|
||||
</MenuItem>
|
||||
<MenuItem icon={<PiPlusBold />} onClick={addIPAdapter}>
|
||||
{t('controlLayers.globalIPAdapter')}
|
||||
{t('controlLayers.ipAdapter', { count: 1 })}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -37,11 +37,11 @@ function formatPct(v: number | string) {
|
||||
return `${round(Number(v), 2).toLocaleString()}%`;
|
||||
}
|
||||
|
||||
function mapSliderValueToRawValue(value: number) {
|
||||
function mapSliderValueToOpacity(value: number) {
|
||||
return value / 100;
|
||||
}
|
||||
|
||||
function mapRawValueToSliderValue(opacity: number) {
|
||||
function mapOpacityToSliderValue(opacity: number) {
|
||||
return opacity * 100;
|
||||
}
|
||||
|
||||
@@ -50,14 +50,14 @@ function formatSliderValue(value: number) {
|
||||
}
|
||||
|
||||
const marks = [
|
||||
mapRawValueToSliderValue(0),
|
||||
mapRawValueToSliderValue(0.25),
|
||||
mapRawValueToSliderValue(0.5),
|
||||
mapRawValueToSliderValue(0.75),
|
||||
mapRawValueToSliderValue(1),
|
||||
mapOpacityToSliderValue(0),
|
||||
mapOpacityToSliderValue(0.25),
|
||||
mapOpacityToSliderValue(0.5),
|
||||
mapOpacityToSliderValue(0.75),
|
||||
mapOpacityToSliderValue(1),
|
||||
];
|
||||
|
||||
const sliderDefaultValue = mapRawValueToSliderValue(1);
|
||||
const sliderDefaultValue = mapOpacityToSliderValue(1);
|
||||
|
||||
const snapCandidates = marks.slice(1, marks.length - 1);
|
||||
|
||||
@@ -95,7 +95,7 @@ export const SelectedEntityOpacity = memo(() => {
|
||||
if (!$shift.get()) {
|
||||
snappedOpacity = snapToNearest(opacity, snapCandidates, 2);
|
||||
}
|
||||
const mappedOpacity = mapSliderValueToRawValue(snappedOpacity);
|
||||
const mappedOpacity = mapSliderValueToOpacity(snappedOpacity);
|
||||
|
||||
dispatch(entityOpacityChanged({ entityIdentifier: selectedEntityIdentifier, opacity: mappedOpacity }));
|
||||
},
|
||||
@@ -157,7 +157,7 @@ export const SelectedEntityOpacity = memo(() => {
|
||||
clampValueOnBlur={false}
|
||||
variant="outline"
|
||||
>
|
||||
<NumberInputField paddingInlineEnd={7} _focusVisible={{ zIndex: 0 }} />
|
||||
<NumberInputField paddingInlineEnd={7} />
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="open-slider"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Button, ButtonGroup } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectCanvasSessionSlice, sessionModeChanged } from 'features/controlLayers/store/canvasSessionSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const selectCanvasMode = createSelector(selectCanvasSessionSlice, (canvasSession) => canvasSession.mode);
|
||||
|
||||
export const CanvasModeSwitcher = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const mode = useAppSelector(selectCanvasMode);
|
||||
const onClickGenerate = useCallback(() => dispatch(sessionModeChanged({ mode: 'generate' })), [dispatch]);
|
||||
const onClickCompose = useCallback(() => dispatch(sessionModeChanged({ mode: 'compose' })), [dispatch]);
|
||||
|
||||
return (
|
||||
<ButtonGroup variant="outline">
|
||||
<Button onClick={onClickGenerate} colorScheme={mode === 'generate' ? 'invokeBlue' : 'base'}>
|
||||
{t('controlLayers.generateMode')}
|
||||
</Button>
|
||||
<Button onClick={onClickCompose} colorScheme={mode === 'compose' ? 'invokeBlue' : 'base'}>
|
||||
{t('controlLayers.composeMode')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasModeSwitcher.displayName = 'CanvasModeSwitcher';
|
||||
@@ -9,7 +9,6 @@ import { memo } from 'react';
|
||||
|
||||
export const CanvasPanelContent = memo(() => {
|
||||
const hasEntities = useAppSelector(selectHasEntities);
|
||||
|
||||
return (
|
||||
<CanvasManagerProviderGate>
|
||||
<Flex flexDir="column" gap={2} w="full" h="full">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { $alt, IconButton } from '@invoke-ai/ui-library';
|
||||
import { $shift, IconButton } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes';
|
||||
import { $canvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
@@ -7,7 +7,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
|
||||
|
||||
export const CanvasToolbarResetViewButton = memo(() => {
|
||||
export const CanvasResetViewButton = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const canvasManager = useStore($canvasManager);
|
||||
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
|
||||
@@ -27,7 +27,7 @@ export const CanvasToolbarResetViewButton = memo(() => {
|
||||
}, [canvasManager]);
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
if ($alt.get()) {
|
||||
if ($shift.get()) {
|
||||
resetView();
|
||||
} else {
|
||||
resetZoom();
|
||||
@@ -35,7 +35,7 @@ export const CanvasToolbarResetViewButton = memo(() => {
|
||||
}, [resetView, resetZoom]);
|
||||
|
||||
useHotkeys('r', resetView, { enabled: isCanvasActive }, [isCanvasActive]);
|
||||
useHotkeys('alt+r', resetZoom, { enabled: isCanvasActive }, [isCanvasActive]);
|
||||
useHotkeys('shift+r', resetZoom, { enabled: isCanvasActive }, [isCanvasActive]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
@@ -48,4 +48,4 @@ export const CanvasToolbarResetViewButton = memo(() => {
|
||||
);
|
||||
});
|
||||
|
||||
CanvasToolbarResetViewButton.displayName = 'CanvasToolbarResetViewButton';
|
||||
CanvasResetViewButton.displayName = 'CanvasResetViewButton';
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { MAX_CANVAS_SCALE, MIN_CANVAS_SCALE } from 'features/controlLayers/konva/constants';
|
||||
import { snapToNearest } from 'features/controlLayers/konva/util';
|
||||
import { round } from 'lodash-es';
|
||||
import { clamp, round } from 'lodash-es';
|
||||
import { computed } from 'nanostores';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
@@ -31,7 +32,7 @@ function formatPct(v: number | string) {
|
||||
return `${round(Number(v), 2).toLocaleString()}%`;
|
||||
}
|
||||
|
||||
function mapSliderValueToRawValue(value: number) {
|
||||
function mapSliderValueToScale(value: number) {
|
||||
if (value <= 40) {
|
||||
// 0 to 40 -> 10% to 100%
|
||||
return 10 + (90 * value) / 40;
|
||||
@@ -44,58 +45,64 @@ function mapSliderValueToRawValue(value: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapRawValueToSliderValue(value: number) {
|
||||
if (value <= 100) {
|
||||
return ((value - 10) * 40) / 90;
|
||||
} else if (value <= 500) {
|
||||
return 40 + ((value - 100) * 30) / 400;
|
||||
function mapScaleToSliderValue(scale: number) {
|
||||
if (scale <= 100) {
|
||||
return ((scale - 10) * 40) / 90;
|
||||
} else if (scale <= 500) {
|
||||
return 40 + ((scale - 100) * 30) / 400;
|
||||
} else {
|
||||
return 70 + ((value - 500) * 30) / 1500;
|
||||
return 70 + ((scale - 500) * 30) / 1500;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSliderValue(value: number) {
|
||||
return String(mapSliderValueToRawValue(value));
|
||||
return String(mapSliderValueToScale(value));
|
||||
}
|
||||
|
||||
const marks = [
|
||||
mapRawValueToSliderValue(10),
|
||||
mapRawValueToSliderValue(50),
|
||||
mapRawValueToSliderValue(100),
|
||||
mapRawValueToSliderValue(500),
|
||||
mapRawValueToSliderValue(2000),
|
||||
mapScaleToSliderValue(10),
|
||||
mapScaleToSliderValue(50),
|
||||
mapScaleToSliderValue(100),
|
||||
mapScaleToSliderValue(500),
|
||||
mapScaleToSliderValue(2000),
|
||||
];
|
||||
|
||||
const sliderDefaultValue = mapRawValueToSliderValue(100);
|
||||
const sliderDefaultValue = mapScaleToSliderValue(100);
|
||||
|
||||
const snapCandidates = marks.slice(1, marks.length - 1);
|
||||
|
||||
export const CanvasToolbarScale = memo(() => {
|
||||
export const CanvasScale = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const canvasManager = useCanvasManager();
|
||||
const scale = useStore(computed(canvasManager.stage.$stageAttrs, (attrs) => attrs.scale));
|
||||
const scale = useStore(computed(canvasManager.stateApi.$stageAttrs, (attrs) => attrs.scale));
|
||||
const [localScale, setLocalScale] = useState(scale * 100);
|
||||
|
||||
const onChangeSlider = useCallback(
|
||||
(scale: number) => {
|
||||
if (!canvasManager) {
|
||||
return;
|
||||
}
|
||||
let snappedScale = scale;
|
||||
// Do not snap if shift key is held
|
||||
if (!$shift.get()) {
|
||||
snappedScale = snapToNearest(scale, snapCandidates, 2);
|
||||
}
|
||||
const mappedScale = mapSliderValueToRawValue(snappedScale);
|
||||
const mappedScale = mapSliderValueToScale(snappedScale);
|
||||
canvasManager.stage.setScale(mappedScale / 100);
|
||||
},
|
||||
[canvasManager]
|
||||
);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
if (!canvasManager) {
|
||||
return;
|
||||
}
|
||||
if (isNaN(Number(localScale))) {
|
||||
canvasManager.stage.setScale(1);
|
||||
setLocalScale(100);
|
||||
return;
|
||||
}
|
||||
canvasManager.stage.setScale(localScale / 100);
|
||||
canvasManager.stage.setScale(clamp(localScale / 100, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE));
|
||||
}, [canvasManager, localScale]);
|
||||
|
||||
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
|
||||
@@ -123,8 +130,8 @@ export const CanvasToolbarScale = memo(() => {
|
||||
<NumberInput
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
min={canvasManager.stage.config.MIN_SCALE * 100}
|
||||
max={canvasManager.stage.config.MAX_SCALE * 100}
|
||||
min={MIN_CANVAS_SCALE * 100}
|
||||
max={MAX_CANVAS_SCALE * 100}
|
||||
value={localScale}
|
||||
onChange={onChangeNumberInput}
|
||||
onBlur={onBlur}
|
||||
@@ -155,7 +162,7 @@ export const CanvasToolbarScale = memo(() => {
|
||||
<CompositeSlider
|
||||
min={0}
|
||||
max={100}
|
||||
value={mapRawValueToSliderValue(localScale)}
|
||||
value={mapScaleToSliderValue(localScale)}
|
||||
onChange={onChangeSlider}
|
||||
defaultValue={sliderDefaultValue}
|
||||
marks={marks}
|
||||
@@ -168,4 +175,4 @@ export const CanvasToolbarScale = memo(() => {
|
||||
);
|
||||
});
|
||||
|
||||
CanvasToolbarScale.displayName = 'CanvasToolbarScale';
|
||||
CanvasScale.displayName = 'CanvasScale';
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { IconSwitch } from 'common/components/IconSwitch';
|
||||
import { selectIsComposing, sessionSendToCanvasChanged } from 'features/controlLayers/store/canvasSessionSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiImageBold, PiPaintBrushBold } from 'react-icons/pi';
|
||||
|
||||
const TooltipSendToGallery = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Text fontWeight="semibold">{t('controlLayers.sendToGallery')}</Text>
|
||||
<Text fontWeight="normal">{t('controlLayers.sendToGalleryDesc')}</Text>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipSendToGallery.displayName = 'TooltipSendToGallery';
|
||||
|
||||
const TooltipSendToCanvas = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Text fontWeight="semibold">{t('controlLayers.sendToCanvas')}</Text>
|
||||
<Text fontWeight="normal">{t('controlLayers.sendToCanvasDesc')}</Text>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipSendToCanvas.displayName = 'TooltipSendToCanvas';
|
||||
|
||||
export const CanvasSendToToggle = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const isComposing = useAppSelector(selectIsComposing);
|
||||
|
||||
const onChange = useCallback(
|
||||
(isChecked: boolean) => {
|
||||
dispatch(sessionSendToCanvasChanged(isChecked));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<IconSwitch
|
||||
isChecked={isComposing}
|
||||
onChange={onChange}
|
||||
iconUnchecked={<PiImageBold />}
|
||||
tooltipUnchecked={<TooltipSendToGallery />}
|
||||
iconChecked={<PiPaintBrushBold />}
|
||||
tooltipChecked={<TooltipSendToCanvas />}
|
||||
ariaLabel="Toggle canvas mode"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasSendToToggle.displayName = 'CanvasSendToToggle';
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Spacer } from '@invoke-ai/ui-library';
|
||||
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
|
||||
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
|
||||
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
|
||||
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
|
||||
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
|
||||
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
|
||||
import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper';
|
||||
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
|
||||
@@ -28,7 +29,8 @@ export const ControlLayer = memo(({ id }: Props) => {
|
||||
<CanvasEntityEditableTitle />
|
||||
<Spacer />
|
||||
<ControlLayerBadges />
|
||||
<CanvasEntityHeaderCommonActions />
|
||||
<CanvasEntityIsLockedToggle />
|
||||
<CanvasEntityEnabledToggle />
|
||||
</CanvasEntityHeader>
|
||||
<CanvasEntitySettingsWrapper>
|
||||
<ControlLayerControlAdapter />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { CanvasEditor } from 'features/controlLayers/components/CanvasEditor';
|
||||
import { CanvasEditor } from 'features/controlLayers/components/ControlLayersEditor';
|
||||
|
||||
const meta: Meta<typeof CanvasEditor> = {
|
||||
title: 'Feature/ControlLayers',
|
||||
@@ -2,12 +2,12 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useScopeOnFocus } from 'common/hooks/interactionScopes';
|
||||
import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea';
|
||||
import { ControlLayersToolbar } from 'features/controlLayers/components/ControlLayersToolbar';
|
||||
import { Filter } from 'features/controlLayers/components/Filters/Filter';
|
||||
import { StageComponent } from 'features/controlLayers/components/StageComponent';
|
||||
import { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate';
|
||||
import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar';
|
||||
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
|
||||
import { Transform } from 'features/controlLayers/components/Transform/Transform';
|
||||
import { Transform } from 'features/controlLayers/components/Transform';
|
||||
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
@@ -28,7 +28,7 @@ export const CanvasEditor = memo(() => {
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<CanvasToolbar />
|
||||
<ControlLayersToolbar />
|
||||
<StageComponent />
|
||||
<Flex position="absolute" bottom={8} gap={2} align="center" justify="center">
|
||||
<CanvasManagerProviderGate>
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
import { Flex, Spacer } from '@invoke-ai/ui-library';
|
||||
import { CanvasModeSwitcher } from 'features/controlLayers/components/CanvasModeSwitcher';
|
||||
import { CanvasResetViewButton } from 'features/controlLayers/components/CanvasResetViewButton';
|
||||
import { CanvasScale } from 'features/controlLayers/components/CanvasScale';
|
||||
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
|
||||
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
|
||||
import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
|
||||
import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings';
|
||||
import { UndoRedoButtonGroup } from 'features/controlLayers/components/UndoRedoButtonGroup';
|
||||
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
|
||||
import { ViewerToggleMenu } from 'features/gallery/components/ImageViewer/ViewerToggleMenu';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const ControlLayersToolbar = memo(() => {
|
||||
return (
|
||||
<CanvasManagerProviderGate>
|
||||
<Flex w="full" gap={2} alignItems="center">
|
||||
<ToggleProgressButton />
|
||||
<ToolChooser />
|
||||
<Spacer />
|
||||
<ToolSettings />
|
||||
<Spacer />
|
||||
<CanvasScale />
|
||||
<CanvasResetViewButton />
|
||||
<Spacer />
|
||||
<ToolFillColorPicker />
|
||||
<CanvasModeSwitcher />
|
||||
<UndoRedoButtonGroup />
|
||||
<CanvasSettingsPopover />
|
||||
<ViewerToggleMenu />
|
||||
</Flex>
|
||||
</CanvasManagerProviderGate>
|
||||
);
|
||||
});
|
||||
|
||||
ControlLayersToolbar.displayName = 'ControlLayersToolbar';
|
||||
@@ -1,27 +1,53 @@
|
||||
import { Grid, GridItem, Text } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
|
||||
import { round } from 'lodash-es';
|
||||
import { memo } from 'react';
|
||||
|
||||
const selectBbox = createSelector(selectCanvasSlice, (canvas) => canvas.bbox);
|
||||
|
||||
export const HeadsUpDisplay = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const stageAttrs = useStore(canvasManager.stateApi.$stageAttrs);
|
||||
const cursorPos = useStore(canvasManager.stateApi.$lastCursorPos);
|
||||
const isDrawing = useStore(canvasManager.stateApi.$isDrawing);
|
||||
const isMouseDown = useStore(canvasManager.stateApi.$isMouseDown);
|
||||
const lastMouseDownPos = useStore(canvasManager.stateApi.$lastMouseDownPos);
|
||||
const lastAddedPoint = useStore(canvasManager.stateApi.$lastAddedPoint);
|
||||
const bbox = useAppSelector(selectBbox);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
bg="base.900"
|
||||
borderBottomEndRadius="base"
|
||||
p={2}
|
||||
gap={2}
|
||||
borderRadius="base"
|
||||
templateColumns="auto auto"
|
||||
opacity={0.6}
|
||||
>
|
||||
<HUDItem label="BBox" value={`${bbox.rect.width}×${bbox.rect.height} px`} />
|
||||
<HUDItem label="Scaled BBox" value={`${bbox.scaledSize.width}×${bbox.scaledSize.height} px`} />
|
||||
</Grid>
|
||||
<Flex flexDir="column" bg="blackAlpha.400" borderBottomEndRadius="base" p={2} minW={64} gap={2}>
|
||||
<HUDItem label="Zoom" value={`${round(stageAttrs.scale * 100, 2)}%`} />
|
||||
<HUDItem label="Stage Pos" value={`${round(stageAttrs.x, 3)}, ${round(stageAttrs.y, 3)}`} />
|
||||
<HUDItem
|
||||
label="Stage Size"
|
||||
value={`${round(stageAttrs.width / stageAttrs.scale, 2)}×${round(stageAttrs.height / stageAttrs.scale, 2)} px`}
|
||||
/>
|
||||
<HUDItem label="BBox Size" value={`${bbox.rect.width}×${bbox.rect.height} px`} />
|
||||
<HUDItem label="BBox Position" value={`${bbox.rect.x}, ${bbox.rect.y}`} />
|
||||
<HUDItem label="BBox Width % 8" value={round(bbox.rect.width % 8, 2)} />
|
||||
<HUDItem label="BBox Height % 8" value={round(bbox.rect.height % 8, 2)} />
|
||||
<HUDItem label="BBox X % 8" value={round(bbox.rect.x % 8, 2)} />
|
||||
<HUDItem label="BBox Y % 8" value={round(bbox.rect.y % 8, 2)} />
|
||||
<HUDItem
|
||||
label="Cursor Position"
|
||||
value={cursorPos ? `${round(cursorPos.x, 2)}, ${round(cursorPos.y, 2)}` : '?, ?'}
|
||||
/>
|
||||
<HUDItem label="Is Drawing" value={isDrawing ? 'True' : 'False'} />
|
||||
<HUDItem label="Is Mouse Down" value={isMouseDown ? 'True' : 'False'} />
|
||||
<HUDItem
|
||||
label="Last Mouse Down Pos"
|
||||
value={lastMouseDownPos ? `${round(lastMouseDownPos.x, 2)}, ${round(lastMouseDownPos.y, 2)}` : '?, ?'}
|
||||
/>
|
||||
<HUDItem
|
||||
label="Last Added Point"
|
||||
value={lastAddedPoint ? `${round(lastAddedPoint.x, 2)}, ${round(lastAddedPoint.y, 2)}` : '?, ?'}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,14 +55,12 @@ HeadsUpDisplay.displayName = 'HeadsUpDisplay';
|
||||
|
||||
const HUDItem = memo(({ label, value }: { label: string; value: string | number }) => {
|
||||
return (
|
||||
<>
|
||||
<GridItem>
|
||||
<Text textAlign="end">{label}: </Text>
|
||||
</GridItem>
|
||||
<GridItem fontWeight="semibold">
|
||||
<Text>{value}</Text>
|
||||
</GridItem>
|
||||
</>
|
||||
<Box display="inline-block" lineHeight={1}>
|
||||
<Text as="span">{label}: </Text>
|
||||
<Text as="span" fontWeight="semibold">
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Spacer } from '@invoke-ai/ui-library';
|
||||
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
|
||||
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
|
||||
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
|
||||
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
|
||||
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
|
||||
import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings';
|
||||
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
@@ -18,10 +18,10 @@ export const IPAdapter = memo(({ id }: Props) => {
|
||||
return (
|
||||
<EntityIdentifierContext.Provider value={entityIdentifier}>
|
||||
<CanvasEntityContainer>
|
||||
<CanvasEntityHeader ps={4} py={5}>
|
||||
<CanvasEntityHeader ps={4}>
|
||||
<CanvasEntityEditableTitle />
|
||||
<Spacer />
|
||||
<CanvasEntityHeaderCommonActions />
|
||||
<CanvasEntityEnabledToggle />
|
||||
</CanvasEntityHeader>
|
||||
<IPAdapterSettings />
|
||||
</CanvasEntityContainer>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Spacer } from '@invoke-ai/ui-library';
|
||||
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
|
||||
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
|
||||
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
|
||||
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
|
||||
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
|
||||
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
|
||||
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
|
||||
import { EntityMaskAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
|
||||
@@ -24,7 +25,8 @@ export const InpaintMask = memo(({ id }: Props) => {
|
||||
<CanvasEntityPreviewImage />
|
||||
<CanvasEntityEditableTitle />
|
||||
<Spacer />
|
||||
<CanvasEntityHeaderCommonActions />
|
||||
<CanvasEntityIsLockedToggle />
|
||||
<CanvasEntityEnabledToggle />
|
||||
</CanvasEntityHeader>
|
||||
</CanvasEntityContainer>
|
||||
</EntityMaskAdapterGate>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Spacer } from '@invoke-ai/ui-library';
|
||||
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
|
||||
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
|
||||
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
|
||||
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
|
||||
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
|
||||
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
|
||||
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
|
||||
import { EntityLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
|
||||
@@ -24,7 +25,8 @@ export const RasterLayer = memo(({ id }: Props) => {
|
||||
<CanvasEntityPreviewImage />
|
||||
<CanvasEntityEditableTitle />
|
||||
<Spacer />
|
||||
<CanvasEntityHeaderCommonActions />
|
||||
<CanvasEntityIsLockedToggle />
|
||||
<CanvasEntityEnabledToggle />
|
||||
</CanvasEntityHeader>
|
||||
</CanvasEntityContainer>
|
||||
</EntityLayerAdapterGate>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Spacer } from '@invoke-ai/ui-library';
|
||||
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
|
||||
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
|
||||
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
|
||||
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
|
||||
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
|
||||
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
|
||||
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
|
||||
import { RegionalGuidanceBadges } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceBadges';
|
||||
@@ -27,7 +28,8 @@ export const RegionalGuidance = memo(({ id }: Props) => {
|
||||
<CanvasEntityEditableTitle />
|
||||
<Spacer />
|
||||
<RegionalGuidanceBadges />
|
||||
<CanvasEntityHeaderCommonActions />
|
||||
<CanvasEntityIsLockedToggle />
|
||||
<CanvasEntityEnabledToggle />
|
||||
</CanvasEntityHeader>
|
||||
<RegionalGuidanceSettings />
|
||||
</CanvasEntityContainer>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { CanvasSettingsInvertScrollCheckbox } from 'features/controlLayers/compo
|
||||
import { CanvasSettingsLogDebugInfoButton } from 'features/controlLayers/components/Settings/CanvasSettingsLogDebugInfo';
|
||||
import { CanvasSettingsRecalculateRectsButton } from 'features/controlLayers/components/Settings/CanvasSettingsRecalculateRectsButton';
|
||||
import { CanvasSettingsResetButton } from 'features/controlLayers/components/Settings/CanvasSettingsResetButton';
|
||||
import { CanvasSettingsShowHUDSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsShowHUDSwitch';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiSettings4Fill } from 'react-icons/ri';
|
||||
@@ -38,7 +37,6 @@ export const CanvasSettingsPopover = memo(() => {
|
||||
<CanvasSettingsInvertScrollCheckbox />
|
||||
<CanvasSettingsClipToBboxCheckbox />
|
||||
<CanvasSettingsDynamicGridSwitch />
|
||||
<CanvasSettingsShowHUDSwitch />
|
||||
<CanvasSettingsResetButton />
|
||||
<DebugSettings />
|
||||
</Flex>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectCanvasSettingsSlice, settingsShowHUDToggled } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const selectShowHUD = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.showHUD);
|
||||
|
||||
export const CanvasSettingsShowHUDSwitch = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const showHUD = useAppSelector(selectShowHUD);
|
||||
const onChange = useCallback(() => {
|
||||
dispatch(settingsShowHUDToggled());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<FormLabel m={0} flexGrow={1}>
|
||||
{t('controlLayers.showHUD')}
|
||||
</FormLabel>
|
||||
<Switch size="sm" isChecked={showHUD} onChange={onChange} />
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasSettingsShowHUDSwitch.displayName = 'CanvasSettingsShowHUDSwitch';
|
||||
@@ -7,19 +7,21 @@ import { useAppStore } from 'app/store/nanostores/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { HeadsUpDisplay } from 'features/controlLayers/components/HeadsUpDisplay';
|
||||
import { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
|
||||
import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
|
||||
import Konva from 'konva';
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useDevicePixelRatio } from 'use-device-pixel-ratio';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const log = logger('canvas');
|
||||
|
||||
const showHud = false;
|
||||
|
||||
// This will log warnings when layers > 5 - maybe use `import.meta.env.MODE === 'development'` instead?
|
||||
Konva.showWarnings = false;
|
||||
|
||||
const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null) => {
|
||||
const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null, asPreview: boolean) => {
|
||||
const store = useAppStore();
|
||||
const socket = useStore($socket);
|
||||
const dpr = useDevicePixelRatio({ round: false });
|
||||
@@ -40,25 +42,28 @@ const useStageRenderer = (stage: Konva.Stage, container: HTMLDivElement | null)
|
||||
const manager = new CanvasManager(stage, container, store, socket);
|
||||
manager.initialize();
|
||||
return manager.destroy;
|
||||
}, [container, socket, stage, store]);
|
||||
}, [asPreview, container, socket, stage, store]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
Konva.pixelRatio = dpr;
|
||||
}, [dpr]);
|
||||
};
|
||||
|
||||
const selectDynamicGrid = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.dynamicGrid);
|
||||
const selectShowHUD = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.showHUD);
|
||||
type Props = {
|
||||
asPreview?: boolean;
|
||||
};
|
||||
|
||||
export const StageComponent = memo(() => {
|
||||
const selectDynamicGrid = createSelector(selectCanvasSettingsSlice, (canvasSettings) => canvasSettings.dynamicGrid);
|
||||
|
||||
export const StageComponent = memo(({ asPreview = false }: Props) => {
|
||||
const dynamicGrid = useAppSelector(selectDynamicGrid);
|
||||
const showHUD = useAppSelector(selectShowHUD);
|
||||
|
||||
const [stage] = useState(
|
||||
() =>
|
||||
new Konva.Stage({
|
||||
id: getPrefixedId('konva_stage'),
|
||||
id: uuidv4(),
|
||||
container: document.createElement('div'),
|
||||
listening: !asPreview,
|
||||
})
|
||||
);
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
@@ -67,7 +72,7 @@ export const StageComponent = memo(() => {
|
||||
setContainer(el);
|
||||
}, []);
|
||||
|
||||
useStageRenderer(stage, container);
|
||||
useStageRenderer(stage, container, asPreview);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -82,7 +87,7 @@ export const StageComponent = memo(() => {
|
||||
<Flex
|
||||
position="absolute"
|
||||
borderRadius="base"
|
||||
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
|
||||
bgImage={TRANSPARENCY_CHECKER_PATTERN}
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
@@ -101,9 +106,9 @@ export const StageComponent = memo(() => {
|
||||
overflow="hidden"
|
||||
data-testid="control-layers-canvas"
|
||||
/>
|
||||
{showHUD && (
|
||||
<Flex position="absolute" top={1} insetInlineStart={1} pointerEvents="none">
|
||||
<HeadsUpDisplay />
|
||||
{!asPreview && (
|
||||
<Flex position="absolute" top={0} insetInlineStart={0} pointerEvents="none">
|
||||
{showHud && <HeadsUpDisplay />}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const StagingAreaToolbar = memo(() => {
|
||||
const index = useAppSelector(selectStagedImageIndex);
|
||||
const selectedImage = useAppSelector(selectSelectedImage);
|
||||
const imageCount = useAppSelector(selectImageCount);
|
||||
const shouldShowStagedImage = useStore(canvasManager.stagingArea.$shouldShowStagedImage);
|
||||
const shouldShowStagedImage = useStore(canvasManager.stateApi.$shouldShowStagedImage);
|
||||
const isCanvasActive = useStore(INTERACTION_SCOPES.canvas.$isActive);
|
||||
const [changeIsImageIntermediate] = useChangeImageIsIntermediateMutation();
|
||||
useScopeOnMount('stagingArea');
|
||||
@@ -83,8 +83,8 @@ export const StagingAreaToolbar = memo(() => {
|
||||
}, [dispatch]);
|
||||
|
||||
const onToggleShouldShowStagedImage = useCallback(() => {
|
||||
canvasManager.stagingArea.$shouldShowStagedImage.set(!shouldShowStagedImage);
|
||||
}, [canvasManager.stagingArea.$shouldShowStagedImage, shouldShowStagedImage]);
|
||||
canvasManager.stateApi.$shouldShowStagedImage.set(!shouldShowStagedImage);
|
||||
}, [canvasManager.stateApi.$shouldShowStagedImage, shouldShowStagedImage]);
|
||||
|
||||
const onSaveStagingImage = useCallback(() => {
|
||||
if (!selectedImage) {
|
||||
@@ -98,9 +98,9 @@ export const StagingAreaToolbar = memo(() => {
|
||||
onPrev,
|
||||
{
|
||||
preventDefault: true,
|
||||
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
|
||||
enabled: isCanvasActive,
|
||||
},
|
||||
[isCanvasActive, shouldShowStagedImage, imageCount]
|
||||
[isCanvasActive]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
@@ -108,9 +108,9 @@ export const StagingAreaToolbar = memo(() => {
|
||||
onNext,
|
||||
{
|
||||
preventDefault: true,
|
||||
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
|
||||
enabled: isCanvasActive,
|
||||
},
|
||||
[isCanvasActive, shouldShowStagedImage, imageCount]
|
||||
[isCanvasActive]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
@@ -118,9 +118,9 @@ export const StagingAreaToolbar = memo(() => {
|
||||
onAccept,
|
||||
{
|
||||
preventDefault: true,
|
||||
enabled: isCanvasActive && shouldShowStagedImage && imageCount > 1,
|
||||
enabled: isCanvasActive,
|
||||
},
|
||||
[isCanvasActive, shouldShowStagedImage, imageCount]
|
||||
[isCanvasActive]
|
||||
);
|
||||
|
||||
const counterText = useMemo(() => {
|
||||
|
||||
@@ -20,12 +20,12 @@ export const ToolBboxButton = memo(() => {
|
||||
return isTransforming || isFiltering || isStaging;
|
||||
}, [isFiltering, isStaging, isTransforming]);
|
||||
|
||||
useHotkeys('c', selectBbox, { enabled: !isDisabled || isSelected }, [selectBbox, isSelected, isDisabled]);
|
||||
useHotkeys('q', selectBbox, { enabled: !isDisabled || isSelected }, [selectBbox, isSelected, isDisabled]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
aria-label={`${t('controlLayers.tool.bbox')} (C)`}
|
||||
tooltip={`${t('controlLayers.tool.bbox')} (C)`}
|
||||
aria-label={`${t('controlLayers.tool.bbox')} (Q)`}
|
||||
tooltip={`${t('controlLayers.tool.bbox')} (Q)`}
|
||||
icon={<PiBoundingBoxBold />}
|
||||
colorScheme={isSelected ? 'invokeBlue' : 'base'}
|
||||
variant="outline"
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import {
|
||||
CompositeNumberInput,
|
||||
CompositeSlider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
@@ -14,172 +11,47 @@ import {
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
|
||||
import { brushWidthChanged, selectToolSlice } from 'features/controlLayers/store/toolSlice';
|
||||
import { clamp } from 'lodash-es';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretDownBold } from 'react-icons/pi';
|
||||
|
||||
const selectBrushWidth = createSelector(selectToolSlice, (tool) => tool.brush.width);
|
||||
const marks = [0, 100, 200, 300];
|
||||
const formatPx = (v: number | string) => `${v} px`;
|
||||
|
||||
function mapSliderValueToRawValue(value: number) {
|
||||
if (value <= 40) {
|
||||
// 0 to 40 on the slider -> 1px to 50px
|
||||
return 1 + (49 * value) / 40;
|
||||
} else if (value <= 70) {
|
||||
// 40 to 70 on the slider -> 50px to 200px
|
||||
return 50 + (150 * (value - 40)) / 30;
|
||||
} else {
|
||||
// 70 to 100 on the slider -> 200px to 600px
|
||||
return 200 + (400 * (value - 70)) / 30;
|
||||
}
|
||||
}
|
||||
|
||||
function mapRawValueToSliderValue(value: number) {
|
||||
if (value <= 50) {
|
||||
// 1px to 50px -> 0 to 40 on the slider
|
||||
return ((value - 1) * 40) / 49;
|
||||
} else if (value <= 200) {
|
||||
// 50px to 200px -> 40 to 70 on the slider
|
||||
return 40 + ((value - 50) * 30) / 150;
|
||||
} else {
|
||||
// 200px to 600px -> 70 to 100 on the slider
|
||||
return 70 + ((value - 200) * 30) / 400;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSliderValue(value: number) {
|
||||
return `${String(mapSliderValueToRawValue(value))} px`;
|
||||
}
|
||||
|
||||
const marks = [
|
||||
mapRawValueToSliderValue(1),
|
||||
mapRawValueToSliderValue(50),
|
||||
mapRawValueToSliderValue(200),
|
||||
mapRawValueToSliderValue(600),
|
||||
];
|
||||
|
||||
const sliderDefaultValue = mapRawValueToSliderValue(50);
|
||||
const selectBrushWidth = createSelector(selectToolSlice, (tool) => tool.brush.width);
|
||||
|
||||
export const ToolBrushWidth = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const isSelected = useToolIsSelected('brush');
|
||||
const width = useAppSelector(selectBrushWidth);
|
||||
const [localValue, setLocalValue] = useState(width);
|
||||
const onChange = useCallback(
|
||||
(v: number) => {
|
||||
dispatch(brushWidthChanged(clamp(Math.round(v), 1, 600)));
|
||||
dispatch(brushWidthChanged(Math.round(v)));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const increment = useCallback(() => {
|
||||
let newWidth = Math.round(width * 1.15);
|
||||
if (newWidth === width) {
|
||||
newWidth += 1;
|
||||
}
|
||||
onChange(newWidth);
|
||||
}, [onChange, width]);
|
||||
|
||||
const decrement = useCallback(() => {
|
||||
let newWidth = Math.round(width * 0.85);
|
||||
if (newWidth === width) {
|
||||
newWidth -= 1;
|
||||
}
|
||||
onChange(newWidth);
|
||||
}, [onChange, width]);
|
||||
|
||||
const onChangeSlider = useCallback(
|
||||
(value: number) => {
|
||||
onChange(mapSliderValueToRawValue(value));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
if (isNaN(Number(localValue))) {
|
||||
onChange(50);
|
||||
setLocalValue(50);
|
||||
} else {
|
||||
onChange(localValue);
|
||||
}
|
||||
}, [localValue, onChange]);
|
||||
|
||||
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
|
||||
setLocalValue(valueAsNumber);
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur();
|
||||
}
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(width);
|
||||
}, [width]);
|
||||
|
||||
useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]);
|
||||
useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
|
||||
<PopoverAnchor>
|
||||
<NumberInput
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<CompositeNumberInput
|
||||
min={1}
|
||||
max={600}
|
||||
value={localValue}
|
||||
onChange={onChangeNumberInput}
|
||||
onBlur={onBlur}
|
||||
w="76px"
|
||||
format={formatPx}
|
||||
defaultValue={50}
|
||||
onKeyDown={onKeyDown}
|
||||
clampValueOnBlur={false}
|
||||
>
|
||||
<NumberInputField paddingInlineEnd={7} />
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="open-slider"
|
||||
icon={<PiCaretDownBold />}
|
||||
size="sm"
|
||||
variant="link"
|
||||
position="absolute"
|
||||
insetInlineEnd={0}
|
||||
h="full"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</NumberInput>
|
||||
</PopoverAnchor>
|
||||
</FormControl>
|
||||
<PopoverContent w={200} pt={0} pb={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider
|
||||
min={0}
|
||||
max={100}
|
||||
value={mapRawValueToSliderValue(localValue)}
|
||||
onChange={onChangeSlider}
|
||||
defaultValue={sliderDefaultValue}
|
||||
marks={marks}
|
||||
formatValue={formatSliderValue}
|
||||
alwaysShowMarks
|
||||
value={width}
|
||||
onChange={onChange}
|
||||
w={24}
|
||||
format={formatPx}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w={200} py={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider min={1} max={300} defaultValue={50} value={width} onChange={onChange} marks={marks} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,16 @@ import { ToolBrushButton } from 'features/controlLayers/components/Tool/ToolBrus
|
||||
import { ToolColorPickerButton } from 'features/controlLayers/components/Tool/ToolColorPickerButton';
|
||||
import { ToolMoveButton } from 'features/controlLayers/components/Tool/ToolMoveButton';
|
||||
import { ToolRectButton } from 'features/controlLayers/components/Tool/ToolRectButton';
|
||||
import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey';
|
||||
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
|
||||
|
||||
import { ToolEraserButton } from './ToolEraserButton';
|
||||
import { ToolViewButton } from './ToolViewButton';
|
||||
|
||||
export const ToolChooser: React.FC = () => {
|
||||
useCanvasResetLayerHotkey();
|
||||
useCanvasDeleteLayerHotkey();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup isAttached>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import {
|
||||
CompositeNumberInput,
|
||||
CompositeSlider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
@@ -14,172 +11,47 @@ import {
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
|
||||
import { eraserWidthChanged, selectToolSlice } from 'features/controlLayers/store/toolSlice';
|
||||
import { clamp } from 'lodash-es';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretDownBold } from 'react-icons/pi';
|
||||
|
||||
const selectEraserWidth = createSelector(selectToolSlice, (tool) => tool.eraser.width);
|
||||
const marks = [0, 100, 200, 300];
|
||||
const formatPx = (v: number | string) => `${v} px`;
|
||||
|
||||
function mapSliderValueToRawValue(value: number) {
|
||||
if (value <= 40) {
|
||||
// 0 to 40 on the slider -> 1px to 50px
|
||||
return 1 + (49 * value) / 40;
|
||||
} else if (value <= 70) {
|
||||
// 40 to 70 on the slider -> 50px to 200px
|
||||
return 50 + (150 * (value - 40)) / 30;
|
||||
} else {
|
||||
// 70 to 100 on the slider -> 200px to 600px
|
||||
return 200 + (400 * (value - 70)) / 30;
|
||||
}
|
||||
}
|
||||
|
||||
function mapRawValueToSliderValue(value: number) {
|
||||
if (value <= 50) {
|
||||
// 1px to 50px -> 0 to 40 on the slider
|
||||
return ((value - 1) * 40) / 49;
|
||||
} else if (value <= 200) {
|
||||
// 50px to 200px -> 40 to 70 on the slider
|
||||
return 40 + ((value - 50) * 30) / 150;
|
||||
} else {
|
||||
// 200px to 600px -> 70 to 100 on the slider
|
||||
return 70 + ((value - 200) * 30) / 400;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSliderValue(value: number) {
|
||||
return `${String(mapSliderValueToRawValue(value))} px`;
|
||||
}
|
||||
|
||||
const marks = [
|
||||
mapRawValueToSliderValue(1),
|
||||
mapRawValueToSliderValue(50),
|
||||
mapRawValueToSliderValue(200),
|
||||
mapRawValueToSliderValue(600),
|
||||
];
|
||||
|
||||
const sliderDefaultValue = mapRawValueToSliderValue(50);
|
||||
const selectEraserWidth = createSelector(selectToolSlice, (tool) => tool.eraser.width);
|
||||
|
||||
export const ToolEraserWidth = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const isSelected = useToolIsSelected('eraser');
|
||||
const width = useAppSelector(selectEraserWidth);
|
||||
const [localValue, setLocalValue] = useState(width);
|
||||
const onChange = useCallback(
|
||||
(v: number) => {
|
||||
dispatch(eraserWidthChanged(clamp(Math.round(v), 1, 600)));
|
||||
dispatch(eraserWidthChanged(Math.round(v)));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const increment = useCallback(() => {
|
||||
let newWidth = Math.round(width * 1.15);
|
||||
if (newWidth === width) {
|
||||
newWidth += 1;
|
||||
}
|
||||
onChange(newWidth);
|
||||
}, [onChange, width]);
|
||||
|
||||
const decrement = useCallback(() => {
|
||||
let newWidth = Math.round(width * 0.85);
|
||||
if (newWidth === width) {
|
||||
newWidth -= 1;
|
||||
}
|
||||
onChange(newWidth);
|
||||
}, [onChange, width]);
|
||||
|
||||
const onChangeSlider = useCallback(
|
||||
(value: number) => {
|
||||
onChange(mapSliderValueToRawValue(value));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
if (isNaN(Number(localValue))) {
|
||||
onChange(50);
|
||||
setLocalValue(50);
|
||||
} else {
|
||||
onChange(localValue);
|
||||
}
|
||||
}, [localValue, onChange]);
|
||||
|
||||
const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => {
|
||||
setLocalValue(valueAsNumber);
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur();
|
||||
}
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(width);
|
||||
}, [width]);
|
||||
|
||||
useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]);
|
||||
useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
|
||||
<PopoverAnchor>
|
||||
<NumberInput
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
<FormControl w="min-content" gap={2}>
|
||||
<FormLabel m={0}>{t('controlLayers.width')}</FormLabel>
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<CompositeNumberInput
|
||||
min={1}
|
||||
max={600}
|
||||
value={localValue}
|
||||
onChange={onChangeNumberInput}
|
||||
onBlur={onBlur}
|
||||
w="76px"
|
||||
format={formatPx}
|
||||
defaultValue={50}
|
||||
onKeyDown={onKeyDown}
|
||||
clampValueOnBlur={false}
|
||||
>
|
||||
<NumberInputField paddingInlineEnd={7} />
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
aria-label="open-slider"
|
||||
icon={<PiCaretDownBold />}
|
||||
size="sm"
|
||||
variant="link"
|
||||
position="absolute"
|
||||
insetInlineEnd={0}
|
||||
h="full"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</NumberInput>
|
||||
</PopoverAnchor>
|
||||
</FormControl>
|
||||
<PopoverContent w={200} pt={0} pb={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider
|
||||
min={0}
|
||||
max={100}
|
||||
value={mapRawValueToSliderValue(localValue)}
|
||||
onChange={onChangeSlider}
|
||||
defaultValue={sliderDefaultValue}
|
||||
marks={marks}
|
||||
formatValue={formatSliderValue}
|
||||
alwaysShowMarks
|
||||
value={width}
|
||||
onChange={onChange}
|
||||
w={24}
|
||||
format={formatPx}
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w={200} py={2} px={4}>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<CompositeSlider min={1} max={300} defaultValue={50} value={width} onChange={onChange} marks={marks} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import IAIColorPicker from 'common/components/IAIColorPicker';
|
||||
@@ -23,13 +23,17 @@ export const ToolFillColorPicker = memo(() => {
|
||||
return (
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<Flex role="button" aria-label={t('controlLayers.fill.fillColor')} tabIndex={-1} w={8} h={8}>
|
||||
<Tooltip label={t('controlLayers.fill.fillColor')}>
|
||||
<Flex w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<Box borderRadius="full" w={6} h={6} borderWidth={1} bg={rgbaColorToString(fill)} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Flex
|
||||
as="button"
|
||||
aria-label={t('controlLayers.brushColor')}
|
||||
borderRadius="full"
|
||||
borderWidth={1}
|
||||
bg={rgbaColorToString(fill)}
|
||||
w={8}
|
||||
h={8}
|
||||
cursor="pointer"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverBody minH={64}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { memo } from 'react';
|
||||
|
||||
export const ToolSettings = memo(() => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const tool = useStore(canvasManager.tool.$tool);
|
||||
const tool = useStore(canvasManager.stateApi.$tool);
|
||||
if (tool === 'brush') {
|
||||
return <ToolBrushWidth />;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { useCallback } from 'react';
|
||||
|
||||
export const useToolIsSelected = (tool: Tool) => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const isSelected = useStore(computed(canvasManager.tool.$tool, (t) => t === tool));
|
||||
const isSelected = useStore(computed(canvasManager.stateApi.$tool, (t) => t === tool));
|
||||
return isSelected;
|
||||
};
|
||||
|
||||
export const useSelectTool = (tool: Tool) => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const setTool = useCallback(() => {
|
||||
canvasManager.tool.$tool.set(tool);
|
||||
}, [canvasManager.tool.$tool, tool]);
|
||||
canvasManager.stateApi.$tool.set(tool);
|
||||
}, [canvasManager.stateApi.$tool, tool]);
|
||||
return setTool;
|
||||
};
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
import { Flex, Spacer } from '@invoke-ai/ui-library';
|
||||
import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover';
|
||||
import { ToolChooser } from 'features/controlLayers/components/Tool/ToolChooser';
|
||||
import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker';
|
||||
import { ToolSettings } from 'features/controlLayers/components/Tool/ToolSettings';
|
||||
import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarResetViewButton';
|
||||
import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton';
|
||||
import { CanvasToolbarScale } from 'features/controlLayers/components/Toolbar/CanvasToolbarScale';
|
||||
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey';
|
||||
import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey';
|
||||
import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey';
|
||||
import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys';
|
||||
import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity';
|
||||
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
|
||||
import { ViewerToggle } from 'features/gallery/components/ImageViewer/ViewerToggleMenu';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const CanvasToolbar = memo(() => {
|
||||
useCanvasResetLayerHotkey();
|
||||
useCanvasDeleteLayerHotkey();
|
||||
useCanvasUndoRedoHotkeys();
|
||||
useCanvasEntityQuickSwitchHotkey();
|
||||
useNextPrevEntityHotkeys();
|
||||
|
||||
return (
|
||||
<CanvasManagerProviderGate>
|
||||
<Flex w="full" gap={2} alignItems="center">
|
||||
<ToggleProgressButton />
|
||||
<ToolChooser />
|
||||
<Spacer />
|
||||
<ToolSettings />
|
||||
<Spacer />
|
||||
<CanvasToolbarScale />
|
||||
<CanvasToolbarResetViewButton />
|
||||
<Spacer />
|
||||
<ToolFillColorPicker />
|
||||
<CanvasToolbarSaveToGalleryButton />
|
||||
<CanvasSettingsPopover />
|
||||
<ViewerToggle />
|
||||
</Flex>
|
||||
</CanvasManagerProviderGate>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasToolbar.displayName = 'CanvasToolbar';
|
||||
@@ -1,53 +0,0 @@
|
||||
import { IconButton, useShiftModifier } from '@invoke-ai/ui-library';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { buildUseBoolean } from 'common/hooks/useBoolean';
|
||||
import { isOk, withResultAsync } from 'common/util/result';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiFloppyDiskBold } from 'react-icons/pi';
|
||||
import { serializeError } from 'serialize-error';
|
||||
|
||||
const log = logger('canvas');
|
||||
|
||||
const [useIsSaving] = buildUseBoolean(false);
|
||||
|
||||
export const CanvasToolbarSaveToGalleryButton = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const shift = useShiftModifier();
|
||||
const canvasManager = useCanvasManager();
|
||||
const isSaving = useIsSaving();
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
isSaving.setTrue();
|
||||
|
||||
const rect = shift ? canvasManager.stateApi.getBbox().rect : canvasManager.stage.getVisibleRect('raster_layer');
|
||||
|
||||
const result = await withResultAsync(() =>
|
||||
canvasManager.compositor.rasterizeAndUploadCompositeRasterLayer(rect, true)
|
||||
);
|
||||
|
||||
if (isOk(result)) {
|
||||
toast({ title: t('controlLayers.savedToGalleryOk') });
|
||||
} else {
|
||||
log.error({ error: serializeError(result.error) }, 'Failed to save canvas to gallery');
|
||||
toast({ title: t('controlLayers.savedToGalleryError'), status: 'error' });
|
||||
}
|
||||
|
||||
isSaving.setFalse();
|
||||
}, [canvasManager.compositor, canvasManager.stage, canvasManager.stateApi, isSaving, shift, t]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
icon={<PiFloppyDiskBold />}
|
||||
isLoading={isSaving.isTrue}
|
||||
aria-label={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}
|
||||
tooltip={shift ? t('controlLayers.saveBboxToGallery') : t('controlLayers.saveCanvasToGallery')}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasToolbarSaveToGalleryButton.displayName = 'CanvasToolbarSaveToGalleryButton';
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Button, ButtonGroup, Flex, Heading, Spacer } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
|
||||
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
|
||||
import {
|
||||
EntityIdentifierContext,
|
||||
useEntityIdentifierContext,
|
||||
} from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { useEntityAdapter } from 'features/controlLayers/hooks/useEntityAdapter';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowsCounterClockwiseBold, PiArrowsOutBold, PiCheckBold, PiXBold } from 'react-icons/pi';
|
||||
import { PiArrowsCounterClockwiseBold, PiCheckBold, PiXBold } from 'react-icons/pi';
|
||||
|
||||
const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter }) => {
|
||||
const TransformBox = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const entityIdentifier = useEntityIdentifierContext();
|
||||
const adapter = useEntityAdapter(entityIdentifier);
|
||||
const isProcessing = useStore(adapter.transformer.$isProcessing);
|
||||
|
||||
return (
|
||||
@@ -25,19 +30,9 @@ const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | Ca
|
||||
transitionDuration="normal"
|
||||
>
|
||||
<Heading size="md" color="base.300" userSelect="none">
|
||||
{t('controlLayers.transform.transform')}
|
||||
{t('controlLayers.tool.transform')}
|
||||
</Heading>
|
||||
<ButtonGroup isAttached={false} size="sm" w="full">
|
||||
<Button
|
||||
leftIcon={<PiArrowsOutBold />}
|
||||
onClick={adapter.transformer.fitProxyRectToBbox}
|
||||
isLoading={isProcessing}
|
||||
loadingText={t('controlLayers.transform.reset')}
|
||||
variant="ghost"
|
||||
>
|
||||
{t('controlLayers.transform.fitToBbox')}
|
||||
</Button>
|
||||
<Spacer />
|
||||
<Button
|
||||
leftIcon={<PiArrowsCounterClockwiseBold />}
|
||||
onClick={adapter.transformer.resetTransform}
|
||||
@@ -45,8 +40,9 @@ const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | Ca
|
||||
loadingText={t('controlLayers.reset')}
|
||||
variant="ghost"
|
||||
>
|
||||
{t('controlLayers.transform.reset')}
|
||||
{t('accessibility.reset')}
|
||||
</Button>
|
||||
<Spacer />
|
||||
<Button
|
||||
leftIcon={<PiCheckBold />}
|
||||
onClick={adapter.transformer.applyTransform}
|
||||
@@ -54,7 +50,7 @@ const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | Ca
|
||||
loadingText={t('common.apply')}
|
||||
variant="ghost"
|
||||
>
|
||||
{t('controlLayers.transform.apply')}
|
||||
{t('common.apply')}
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<PiXBold />}
|
||||
@@ -63,7 +59,7 @@ const TransformBox = memo(({ adapter }: { adapter: CanvasEntityLayerAdapter | Ca
|
||||
loadingText={t('common.cancel')}
|
||||
variant="ghost"
|
||||
>
|
||||
{t('controlLayers.transform.cancel')}
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
@@ -74,11 +70,15 @@ TransformBox.displayName = 'Transform';
|
||||
|
||||
export const Transform = () => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const adapter = useStore(canvasManager.stateApi.$transformingAdapter);
|
||||
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
|
||||
|
||||
if (!adapter) {
|
||||
if (!transformingEntity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TransformBox adapter={adapter} />;
|
||||
return (
|
||||
<EntityIdentifierContext.Provider value={transformingEntity}>
|
||||
<TransformBox />
|
||||
</EntityIdentifierContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
import { ButtonGroup, IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { canvasRedo, canvasUndo } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectCanvasMayRedo, selectCanvasMayUndo } from 'features/controlLayers/store/selectors';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiArrowClockwiseBold, PiArrowCounterClockwiseBold } from 'react-icons/pi';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
export const UndoRedoButtonGroup = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const mayUndo = useAppSelector(selectCanvasMayUndo);
|
||||
const handleUndo = useCallback(() => {
|
||||
dispatch(canvasUndo());
|
||||
}, [dispatch]);
|
||||
useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { enabled: mayUndo, preventDefault: true }, [mayUndo, handleUndo]);
|
||||
|
||||
const mayRedo = useAppSelector(selectCanvasMayRedo);
|
||||
const handleRedo = useCallback(() => {
|
||||
dispatch(canvasRedo());
|
||||
}, [dispatch]);
|
||||
useHotkeys(['meta+shift+z', 'ctrl+shift+z'], handleRedo, { enabled: mayRedo, preventDefault: true }, [
|
||||
mayRedo,
|
||||
handleRedo,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ButtonGroup isAttached={false}>
|
||||
<IconButton
|
||||
aria-label={t('unifiedCanvas.undo')}
|
||||
tooltip={t('unifiedCanvas.undo')}
|
||||
onClick={handleUndo}
|
||||
icon={<PiArrowCounterClockwiseBold />}
|
||||
isDisabled={!mayUndo}
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={t('unifiedCanvas.redo')}
|
||||
tooltip={t('unifiedCanvas.redo')}
|
||||
onClick={handleRedo}
|
||||
icon={<PiArrowClockwiseBold />}
|
||||
isDisabled={!mayRedo}
|
||||
variant="ghost"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
});
|
||||
|
||||
UndoRedoButtonGroup.displayName = 'UndoRedoButtonGroup';
|
||||
@@ -1,70 +0,0 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import {
|
||||
controlLayerAdded,
|
||||
inpaintMaskAdded,
|
||||
ipaAdded,
|
||||
rasterLayerAdded,
|
||||
rgAdded,
|
||||
} from 'features/controlLayers/store/canvasSlice';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiPlusBold } from 'react-icons/pi';
|
||||
|
||||
type Props = {
|
||||
type: CanvasEntityIdentifier['type'];
|
||||
};
|
||||
|
||||
export const CanvasEntityAddOfTypeButton = memo(({ type }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
switch (type) {
|
||||
case 'inpaint_mask':
|
||||
dispatch(inpaintMaskAdded({ isSelected: true }));
|
||||
break;
|
||||
case 'regional_guidance':
|
||||
dispatch(rgAdded({ isSelected: true }));
|
||||
break;
|
||||
case 'raster_layer':
|
||||
dispatch(rasterLayerAdded({ isSelected: true }));
|
||||
break;
|
||||
case 'control_layer':
|
||||
dispatch(controlLayerAdded({ isSelected: true }));
|
||||
break;
|
||||
case 'ip_adapter':
|
||||
dispatch(ipaAdded({ isSelected: true }));
|
||||
break;
|
||||
}
|
||||
}, [dispatch, type]);
|
||||
|
||||
const label = useMemo(() => {
|
||||
switch (type) {
|
||||
case 'inpaint_mask':
|
||||
return t('controlLayers.addInpaintMask');
|
||||
case 'regional_guidance':
|
||||
return t('controlLayers.addRegionalGuidance');
|
||||
case 'raster_layer':
|
||||
return t('controlLayers.addRasterLayer');
|
||||
case 'control_layer':
|
||||
return t('controlLayers.addControlLayer');
|
||||
case 'ip_adapter':
|
||||
return t('controlLayers.addIPAdapter');
|
||||
}
|
||||
}, [type, t]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={label}
|
||||
tooltip={label}
|
||||
variant="link"
|
||||
icon={<PiPlusBold />}
|
||||
onClick={onClick}
|
||||
alignSelf="stretch"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityAddOfTypeButton.displayName = 'CanvasEntityAddOfTypeButton';
|
||||
@@ -1,31 +0,0 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { entityDeleted } from 'features/controlLayers/store/canvasSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiTrashSimpleFill } from 'react-icons/pi';
|
||||
|
||||
export const CanvasEntityDeleteButton = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const entityIdentifier = useEntityIdentifierContext();
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(entityDeleted({ entityIdentifier }));
|
||||
}, [dispatch, entityIdentifier]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={t('common.delete')}
|
||||
tooltip={t('common.delete')}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
icon={<PiTrashSimpleFill />}
|
||||
onClick={onClick}
|
||||
colorScheme="error"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityDeleteButton.displayName = 'CanvasEntityDeleteButton';
|
||||
@@ -21,8 +21,7 @@ export const CanvasEntityEnabledToggle = memo(() => {
|
||||
size="sm"
|
||||
aria-label={t(isEnabled ? 'common.enabled' : 'common.disabled')}
|
||||
tooltip={t(isEnabled ? 'common.enabled' : 'common.disabled')}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
variant="ghost"
|
||||
icon={isEnabled ? <PiCircleFill /> : <PiCircleBold />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library';
|
||||
import { useBoolean } from 'common/hooks/useBoolean';
|
||||
import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton';
|
||||
import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton';
|
||||
import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
|
||||
import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { PiCaretDownBold } from 'react-icons/pi';
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
@@ -22,9 +20,6 @@ const _hover: SystemStyleObject = {
|
||||
export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props) => {
|
||||
const title = useEntityTypeTitle(type);
|
||||
const collapse = useBoolean(true);
|
||||
const canMergeVisible = useMemo(() => type === 'raster_layer' || type === 'inpaint_mask', [type]);
|
||||
const canHideAll = useMemo(() => type !== 'ip_adapter', [type]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" w="full">
|
||||
<Flex w="full">
|
||||
@@ -58,9 +53,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props
|
||||
</Text>
|
||||
<Spacer />
|
||||
</Flex>
|
||||
{canMergeVisible && <CanvasEntityMergeVisibleButton type={type} />}
|
||||
<CanvasEntityAddOfTypeButton type={type} />
|
||||
{canHideAll && <CanvasEntityTypeIsHiddenToggle type={type} />}
|
||||
{type !== 'ip_adapter' && <CanvasEntityTypeIsHiddenToggle type={type} />}
|
||||
</Flex>
|
||||
<Collapse in={collapse.isTrue}>
|
||||
<Flex flexDir="column" gap={2} pt={2}>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const CanvasEntityHeader = memo(({ children, ...rest }: FlexProps) => {
|
||||
}, [entityIdentifier]);
|
||||
|
||||
return (
|
||||
<ContextMenu renderMenu={renderMenu}>
|
||||
<ContextMenu renderMenu={renderMenu} stopImmediatePropagation>
|
||||
{(ref) => (
|
||||
<Flex ref={ref} gap={2} alignItems="center" p={2} {...rest}>
|
||||
{children}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { CanvasEntityDeleteButton } from 'features/controlLayers/components/common/CanvasEntityDeleteButton';
|
||||
import { CanvasEntityEnabledToggle } from 'features/controlLayers/components/common/CanvasEntityEnabledToggle';
|
||||
import { CanvasEntityIsBookmarkedForQuickSwitchToggle } from 'features/controlLayers/components/common/CanvasEntityIsBookmarkedForQuickSwitchToggle';
|
||||
import { CanvasEntityIsLockedToggle } from 'features/controlLayers/components/common/CanvasEntityIsLockedToggle';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const CanvasEntityHeaderCommonActions = memo(() => {
|
||||
const entityIdentifier = useEntityIdentifierContext();
|
||||
|
||||
return (
|
||||
<Flex alignSelf="stretch">
|
||||
<CanvasEntityIsBookmarkedForQuickSwitchToggle />
|
||||
{entityIdentifier.type !== 'ip_adapter' && <CanvasEntityIsLockedToggle />}
|
||||
<CanvasEntityEnabledToggle />
|
||||
<CanvasEntityDeleteButton />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityHeaderCommonActions.displayName = 'CanvasEntityHeaderCommonActions';
|
||||
@@ -1,36 +0,0 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { useEntityIsBookmarkedForQuickSwitch } from 'features/controlLayers/hooks/useEntityIsBookmarkedForQuickSwitch';
|
||||
import { bookmarkedEntityChanged } from 'features/controlLayers/store/canvasSlice';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiBookmarkSimpleBold, PiBookmarkSimpleFill } from 'react-icons/pi';
|
||||
|
||||
export const CanvasEntityIsBookmarkedForQuickSwitchToggle = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const entityIdentifier = useEntityIdentifierContext();
|
||||
const isBookmarked = useEntityIsBookmarkedForQuickSwitch(entityIdentifier);
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
if (isBookmarked) {
|
||||
dispatch(bookmarkedEntityChanged({ entityIdentifier: null }));
|
||||
} else {
|
||||
dispatch(bookmarkedEntityChanged({ entityIdentifier }));
|
||||
}
|
||||
}, [dispatch, entityIdentifier, isBookmarked]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={t(isBookmarked ? 'controlLayers.removeBookmark' : 'controlLayers.bookmark')}
|
||||
tooltip={t(isBookmarked ? 'controlLayers.removeBookmark' : 'controlLayers.bookmark')}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
icon={isBookmarked ? <PiBookmarkSimpleFill /> : <PiBookmarkSimpleBold />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityIsBookmarkedForQuickSwitchToggle.displayName = 'CanvasEntityIsBookmarkedForQuickSwitchToggle';
|
||||
@@ -21,8 +21,7 @@ export const CanvasEntityIsLockedToggle = memo(() => {
|
||||
size="sm"
|
||||
aria-label={t(isLocked ? 'controlLayers.locked' : 'controlLayers.unlocked')}
|
||||
tooltip={t(isLocked ? 'controlLayers.locked' : 'controlLayers.unlocked')}
|
||||
variant="link"
|
||||
alignSelf="stretch"
|
||||
variant="ghost"
|
||||
icon={isLocked ? <PiLockSimpleFill /> : <PiLockSimpleOpenBold />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
|
||||
@@ -12,15 +12,15 @@ export const CanvasEntityMenuItemsTransform = memo(() => {
|
||||
const entityIdentifier = useEntityIdentifierContext();
|
||||
const canvasManager = useCanvasManager();
|
||||
const adapter = useEntityAdapter(entityIdentifier);
|
||||
const isTransforming = useStore(canvasManager.stateApi.$isTranforming);
|
||||
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
adapter.transformer.startTransform();
|
||||
}, [adapter.transformer]);
|
||||
|
||||
return (
|
||||
<MenuItem onClick={onClick} icon={<PiFrameCornersBold />} isDisabled={isTransforming}>
|
||||
{t('controlLayers.transform.transform')}
|
||||
<MenuItem onClick={onClick} icon={<PiFrameCornersBold />} isDisabled={Boolean(transformingEntity)}>
|
||||
{t('controlLayers.tool.transform')}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { isOk, withResultAsync } from 'common/util/result';
|
||||
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { useEntityTypeCount } from 'features/controlLayers/hooks/useEntityTypeCount';
|
||||
import { inpaintMaskAdded, rasterLayerAdded } from 'features/controlLayers/store/canvasSlice';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import { imageDTOToImageObject } from 'features/controlLayers/store/types';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiStackBold } from 'react-icons/pi';
|
||||
import { serializeError } from 'serialize-error';
|
||||
|
||||
const log = logger('canvas');
|
||||
|
||||
type Props = {
|
||||
type: CanvasEntityIdentifier['type'];
|
||||
};
|
||||
|
||||
export const CanvasEntityMergeVisibleButton = memo(({ type }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const canvasManager = useCanvasManager();
|
||||
const entityCount = useEntityTypeCount(type);
|
||||
const onClick = useCallback(async () => {
|
||||
if (type === 'raster_layer') {
|
||||
const rect = canvasManager.stage.getVisibleRect('raster_layer');
|
||||
const result = await withResultAsync(() =>
|
||||
canvasManager.compositor.rasterizeAndUploadCompositeRasterLayer(rect, false)
|
||||
);
|
||||
|
||||
if (isOk(result)) {
|
||||
dispatch(
|
||||
rasterLayerAdded({
|
||||
isSelected: true,
|
||||
overrides: {
|
||||
objects: [imageDTOToImageObject(result.value)],
|
||||
position: { x: Math.floor(rect.x), y: Math.floor(rect.y) },
|
||||
},
|
||||
deleteOthers: true,
|
||||
})
|
||||
);
|
||||
toast({ title: t('controlLayers.mergeVisibleOk') });
|
||||
} else {
|
||||
log.error({ error: serializeError(result.error) }, 'Failed to merge visible');
|
||||
toast({ title: t('controlLayers.mergeVisibleError'), status: 'error' });
|
||||
}
|
||||
} else if (type === 'inpaint_mask') {
|
||||
const rect = canvasManager.stage.getVisibleRect('inpaint_mask');
|
||||
const result = await withResultAsync(() =>
|
||||
canvasManager.compositor.rasterizeAndUploadCompositeInpaintMask(rect, false)
|
||||
);
|
||||
|
||||
if (isOk(result)) {
|
||||
dispatch(
|
||||
inpaintMaskAdded({
|
||||
isSelected: true,
|
||||
overrides: {
|
||||
objects: [imageDTOToImageObject(result.value)],
|
||||
position: { x: Math.floor(rect.x), y: Math.floor(rect.y) },
|
||||
},
|
||||
deleteOthers: true,
|
||||
})
|
||||
);
|
||||
toast({ title: t('controlLayers.mergeVisibleOk') });
|
||||
} else {
|
||||
log.error({ error: serializeError(result.error) }, 'Failed to merge visible');
|
||||
toast({ title: t('controlLayers.mergeVisibleError'), status: 'error' });
|
||||
}
|
||||
} else {
|
||||
log.error({ type }, 'Unsupported type for merge visible');
|
||||
}
|
||||
}, [canvasManager.compositor, canvasManager.stage, dispatch, t, type]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={t('controlLayers.mergeVisible')}
|
||||
tooltip={t('controlLayers.mergeVisible')}
|
||||
variant="link"
|
||||
icon={<PiStackBold />}
|
||||
onClick={onClick}
|
||||
alignSelf="stretch"
|
||||
isDisabled={entityCount <= 1}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CanvasEntityMergeVisibleButton.displayName = 'CanvasEntityMergeVisibleButton';
|
||||
@@ -4,7 +4,7 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
||||
import { useEntityAdapter } from 'features/controlLayers/contexts/EntityAdapterContext';
|
||||
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
|
||||
import { TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern';
|
||||
import { TRANSPARENCY_CHECKER_PATTERN } from 'features/controlLayers/konva/constants';
|
||||
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -69,7 +69,7 @@ export const CanvasEntityPreviewImage = memo(() => {
|
||||
ctx.globalCompositeOperation = 'source-in';
|
||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||
}
|
||||
}, [cache, maskColor]);
|
||||
}, [adapter.transformer, adapter.transformer.nodeRect, adapter.transformer.pixelRect, cache, maskColor]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -88,7 +88,7 @@ export const CanvasEntityPreviewImage = memo(() => {
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
bgImage={TRANSPARENCY_CHECKERBOARD_PATTERN_DATAURL}
|
||||
bgImage={TRANSPARENCY_CHECKER_PATTERN}
|
||||
bgSize="5px"
|
||||
opacity={0.1}
|
||||
/>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
|
||||
import {
|
||||
selectBookmarkedEntityIdentifier,
|
||||
selectSelectedEntityIdentifier,
|
||||
} from 'features/controlLayers/store/selectors';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
export const useCanvasEntityQuickSwitchHotkey = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [prev, setPrev] = useState<CanvasEntityIdentifier | null>(null);
|
||||
const [current, setCurrent] = useState<CanvasEntityIdentifier | null>(null);
|
||||
const selected = useAppSelector(selectSelectedEntityIdentifier);
|
||||
const bookmarked = useAppSelector(selectBookmarkedEntityIdentifier);
|
||||
|
||||
// Update prev and current when selected entity changes
|
||||
useEffect(() => {
|
||||
if (current?.id !== selected?.id) {
|
||||
setPrev(current);
|
||||
setCurrent(selected);
|
||||
}
|
||||
}, [current, selected]);
|
||||
|
||||
const onQuickSwitch = useCallback(() => {
|
||||
if (bookmarked) {
|
||||
if (current?.id !== bookmarked.id) {
|
||||
// Switch between current (non-bookmarked) and bookmarked
|
||||
setPrev(current);
|
||||
setCurrent(bookmarked);
|
||||
dispatch(entitySelected({ entityIdentifier: bookmarked }));
|
||||
} else if (prev) {
|
||||
// Switch back to the last non-bookmarked entity
|
||||
setCurrent(prev);
|
||||
dispatch(entitySelected({ entityIdentifier: prev }));
|
||||
}
|
||||
} else if (prev !== null && current !== null) {
|
||||
// Switch between prev and current if no bookmarked entity
|
||||
setPrev(current);
|
||||
setCurrent(prev);
|
||||
dispatch(entitySelected({ entityIdentifier: prev }));
|
||||
}
|
||||
}, [bookmarked, current, dispatch, prev]);
|
||||
|
||||
useHotkeys('q', onQuickSwitch);
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { canvasRedo, canvasUndo } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectCanvasMayRedo, selectCanvasMayUndo } from 'features/controlLayers/store/selectors';
|
||||
import { useCallback } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
export const useCanvasUndoRedoHotkeys = () => {
|
||||
useAssertSingleton('useCanvasUndoRedo');
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const mayUndo = useAppSelector(selectCanvasMayUndo);
|
||||
const handleUndo = useCallback(() => {
|
||||
dispatch(canvasUndo());
|
||||
}, [dispatch]);
|
||||
useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { enabled: mayUndo, preventDefault: true }, [mayUndo, handleUndo]);
|
||||
|
||||
const mayRedo = useAppSelector(selectCanvasMayRedo);
|
||||
const handleRedo = useCallback(() => {
|
||||
dispatch(canvasRedo());
|
||||
}, [dispatch]);
|
||||
useHotkeys(['meta+shift+z', 'ctrl+shift+z'], handleRedo, { enabled: mayRedo, preventDefault: true }, [
|
||||
mayRedo,
|
||||
handleRedo,
|
||||
]);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useEntityIsBookmarkedForQuickSwitch = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
const selectIsBookmarkedForQuickSwitch = useMemo(
|
||||
() =>
|
||||
createSelector(selectCanvasSlice, (canvas) => {
|
||||
return canvas.bookmarkedEntityIdentifier?.id === entityIdentifier.id;
|
||||
}),
|
||||
[entityIdentifier]
|
||||
);
|
||||
const isBookmarkedForQuickSwitch = useAppSelector(selectIsBookmarkedForQuickSwitch);
|
||||
|
||||
return isBookmarkedForQuickSwitch;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
|
||||
import { type CanvasEntityIdentifier, isDrawableEntity } from 'features/controlLayers/store/types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useEntityObjectCount = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
const selectObjectCount = useMemo(
|
||||
() =>
|
||||
createSelector(selectCanvasSlice, (canvas) => {
|
||||
const entity = selectEntity(canvas, entityIdentifier);
|
||||
if (!entity) {
|
||||
return 0;
|
||||
} else if (isDrawableEntity(entity)) {
|
||||
return entity.objects.length;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}),
|
||||
[entityIdentifier]
|
||||
);
|
||||
const objectCount = useAppSelector(selectObjectCount);
|
||||
return objectCount;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useEntityObjectCount } from 'features/controlLayers/hooks/useEntityObjectCount';
|
||||
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
|
||||
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import { useMemo } from 'react';
|
||||
@@ -19,27 +20,34 @@ export const useEntityTitle = (entityIdentifier: CanvasEntityIdentifier) => {
|
||||
const { t } = useTranslation();
|
||||
const selectName = useMemo(() => createSelectName(entityIdentifier), [entityIdentifier]);
|
||||
const name = useAppSelector(selectName);
|
||||
const objectCount = useEntityObjectCount(entityIdentifier);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
switch (entityIdentifier.type) {
|
||||
case 'inpaint_mask':
|
||||
return t('controlLayers.inpaintMask');
|
||||
case 'control_layer':
|
||||
return t('controlLayers.controlLayer');
|
||||
case 'raster_layer':
|
||||
return t('controlLayers.rasterLayer');
|
||||
case 'ip_adapter':
|
||||
return t('controlLayers.globalIPAdapter');
|
||||
case 'regional_guidance':
|
||||
return t('controlLayers.regionalGuidance');
|
||||
default:
|
||||
assert(false, 'Unexpected entity type');
|
||||
const parts: string[] = [];
|
||||
if (entityIdentifier.type === 'inpaint_mask') {
|
||||
parts.push(t('controlLayers.inpaintMask', { count: 1 }));
|
||||
} else if (entityIdentifier.type === 'control_layer') {
|
||||
parts.push(t('controlLayers.controlLayer', { count: 1 }));
|
||||
} else if (entityIdentifier.type === 'raster_layer') {
|
||||
parts.push(t('controlLayers.rasterLayer', { count: 1 }));
|
||||
} else if (entityIdentifier.type === 'ip_adapter') {
|
||||
parts.push(t('common.ipAdapter', { count: 1 }));
|
||||
} else if (entityIdentifier.type === 'regional_guidance') {
|
||||
parts.push(t('controlLayers.regionalGuidance', { count: 1 }));
|
||||
} else {
|
||||
assert(false, 'Unexpected entity type');
|
||||
}
|
||||
}, [entityIdentifier.type, name, t]);
|
||||
|
||||
if (objectCount > 0) {
|
||||
parts.push(`(${objectCount})`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}, [entityIdentifier.type, name, objectCount, t]);
|
||||
|
||||
return title;
|
||||
};
|
||||
|
||||
@@ -8,15 +8,15 @@ export const useEntityTypeString = (type: CanvasEntityIdentifier['type']): strin
|
||||
const typeString = useMemo(() => {
|
||||
switch (type) {
|
||||
case 'control_layer':
|
||||
return t('controlLayers.controlLayer');
|
||||
return t('controlLayers.controlLayer', { count: 0 });
|
||||
case 'raster_layer':
|
||||
return t('controlLayers.rasterLayer');
|
||||
return t('controlLayers.rasterLayer', { count: 0 });
|
||||
case 'inpaint_mask':
|
||||
return t('controlLayers.inpaintMask');
|
||||
return t('controlLayers.inpaintMask', { count: 0 });
|
||||
case 'regional_guidance':
|
||||
return t('controlLayers.regionalGuidance');
|
||||
return t('controlLayers.regionalGuidance', { count: 0 });
|
||||
case 'ip_adapter':
|
||||
return t('controlLayers.globalIPAdapter');
|
||||
return t('controlLayers.ipAdapter', { count: 0 });
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const useEntityTypeTitle = (type: CanvasEntityIdentifier['type']): string
|
||||
case 'regional_guidance':
|
||||
return t('controlLayers.regionalGuidance_withCount', { count, context });
|
||||
case 'ip_adapter':
|
||||
return t('controlLayers.globalIPAdapters_withCount', { count, context });
|
||||
return t('controlLayers.ipAdapters_withCount', { count, context });
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
|
||||
|
||||
export const useIsTransforming = () => {
|
||||
const canvasManager = useCanvasManager();
|
||||
const transformingEntity = useStore(canvasManager.stateApi.$transformingAdapter);
|
||||
const transformingEntity = useStore(canvasManager.stateApi.$transformingEntity);
|
||||
const isTransforming = useMemo(() => {
|
||||
return Boolean(transformingEntity);
|
||||
}, [transformingEntity]);
|
||||
|
||||
@@ -48,6 +48,7 @@ export const useDefaultControlAdapter = (): ControlNetConfig | T2IAdapterConfig
|
||||
return defaultControlAdapter;
|
||||
};
|
||||
|
||||
/** @knipignore */
|
||||
export const useDefaultIPAdapter = (): IPAdapterConfig => {
|
||||
const [modelConfigs] = useIPAdapterModels();
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
|
||||
import { selectAllEntities, selectCanvasSlice } from 'features/controlLayers/store/selectors';
|
||||
import type { CanvasEntityState } from 'features/controlLayers/store/types';
|
||||
import { getEntityIdentifier } from 'features/controlLayers/store/types';
|
||||
import { useCallback } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
const selectNextEntityIdentifier = createMemoizedSelector(selectCanvasSlice, (canvas) => {
|
||||
const selectedEntityIdentifier = canvas.selectedEntityIdentifier;
|
||||
const allEntities = selectAllEntities(canvas);
|
||||
let nextEntity: CanvasEntityState | null = null;
|
||||
if (!selectedEntityIdentifier) {
|
||||
nextEntity = allEntities[0] ?? null;
|
||||
} else {
|
||||
const selectedEntityIndex = allEntities.findIndex((entity) => entity.id === selectedEntityIdentifier.id);
|
||||
nextEntity = allEntities[(selectedEntityIndex + 1) % allEntities.length] ?? null;
|
||||
}
|
||||
if (!nextEntity) {
|
||||
return null;
|
||||
}
|
||||
return getEntityIdentifier(nextEntity);
|
||||
});
|
||||
|
||||
const selectPrevEntityIdentifier = createMemoizedSelector(selectCanvasSlice, (canvas) => {
|
||||
const selectedEntityIdentifier = canvas.selectedEntityIdentifier;
|
||||
const allEntities = selectAllEntities(canvas);
|
||||
let prevEntity: CanvasEntityState | null = null;
|
||||
if (!selectedEntityIdentifier) {
|
||||
prevEntity = allEntities[0] ?? null;
|
||||
} else {
|
||||
const selectedEntityIndex = allEntities.findIndex((entity) => entity.id === selectedEntityIdentifier.id);
|
||||
prevEntity = allEntities[(selectedEntityIndex - 1 + allEntities.length) % allEntities.length] ?? null;
|
||||
}
|
||||
if (!prevEntity) {
|
||||
return null;
|
||||
}
|
||||
return getEntityIdentifier(prevEntity);
|
||||
});
|
||||
|
||||
export const useNextPrevEntityHotkeys = () => {
|
||||
useAssertSingleton('useNextPrevEntityHotkeys');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const nextEntityIdentifier = useAppSelector(selectNextEntityIdentifier);
|
||||
const prevEntityIdentifier = useAppSelector(selectPrevEntityIdentifier);
|
||||
|
||||
const selectNextEntity = useCallback(() => {
|
||||
if (nextEntityIdentifier) {
|
||||
dispatch(entitySelected({ entityIdentifier: nextEntityIdentifier }));
|
||||
}
|
||||
}, [dispatch, nextEntityIdentifier]);
|
||||
|
||||
const selectPrevEntity = useCallback(() => {
|
||||
if (prevEntityIdentifier) {
|
||||
dispatch(entitySelected({ entityIdentifier: prevEntityIdentifier }));
|
||||
}
|
||||
}, [dispatch, prevEntityIdentifier]);
|
||||
|
||||
useHotkeys(
|
||||
// “ === alt+[
|
||||
['alt+[', '“'],
|
||||
selectPrevEntity,
|
||||
{ preventDefault: true, ignoreModifiers: true },
|
||||
[selectPrevEntity]
|
||||
);
|
||||
useHotkeys(
|
||||
// ‘ === alt+]
|
||||
['alt+]', '‘'],
|
||||
selectNextEntity,
|
||||
{ preventDefault: true, ignoreModifiers: true },
|
||||
[selectNextEntity]
|
||||
);
|
||||
};
|
||||
@@ -1,72 +1,45 @@
|
||||
import { getArbitraryBaseColor } from '@invoke-ai/ui-library';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import Konva from 'konva';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
type CanvasBackgroundModuleConfig = {
|
||||
GRID_LINE_COLOR_COARSE: string;
|
||||
GRID_LINE_COLOR_FINE: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: CanvasBackgroundModuleConfig = {
|
||||
GRID_LINE_COLOR_COARSE: getArbitraryBaseColor(27),
|
||||
GRID_LINE_COLOR_FINE: getArbitraryBaseColor(18),
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a background grid on the canvas, where the grid spacing changes based on the stage scale.
|
||||
*
|
||||
* The grid is only visible when the dynamic grid setting is enabled.
|
||||
*/
|
||||
export class CanvasBackgroundModule extends CanvasModuleBase {
|
||||
export class CanvasBackgroundModule extends CanvasModuleABC {
|
||||
readonly type = 'background';
|
||||
|
||||
static GRID_LINE_COLOR_COARSE = getArbitraryBaseColor(27);
|
||||
static GRID_LINE_COLOR_FINE = getArbitraryBaseColor(18);
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
parent: CanvasManager;
|
||||
manager: CanvasManager;
|
||||
subscriptions = new Set<() => void>();
|
||||
log: Logger;
|
||||
|
||||
subscriptions = new Set<() => void>();
|
||||
config: CanvasBackgroundModuleConfig = DEFAULT_CONFIG;
|
||||
|
||||
/**
|
||||
* The Konva objects that make up the background grid:
|
||||
* - A layer to hold the grid lines
|
||||
* - An array of grid lines
|
||||
*/
|
||||
konva: {
|
||||
layer: Konva.Layer;
|
||||
lines: Konva.Line[];
|
||||
};
|
||||
|
||||
constructor(manager: CanvasManager) {
|
||||
super();
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.manager = manager;
|
||||
this.parent = manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.path = this.manager.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
|
||||
this.log.debug('Creating module');
|
||||
this.log.debug('Creating background module');
|
||||
|
||||
this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }), lines: [] };
|
||||
this.konva = { layer: new Konva.Layer({ name: `${this.type}:layer`, listening: false }) };
|
||||
|
||||
/**
|
||||
* The background grid should be rendered when the stage attributes change:
|
||||
* - scale
|
||||
* - position
|
||||
* - size
|
||||
*/
|
||||
this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render));
|
||||
this.subscriptions.add(
|
||||
this.manager.stateApi.$stageAttrs.listen(() => {
|
||||
this.render();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the background grid.
|
||||
*/
|
||||
render = () => {
|
||||
render() {
|
||||
const settings = this.manager.stateApi.getSettings();
|
||||
|
||||
if (!settings.dynamicGrid) {
|
||||
@@ -76,10 +49,11 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
|
||||
|
||||
this.konva.layer.visible(true);
|
||||
|
||||
this.konva.layer.zIndex(0);
|
||||
const scale = this.manager.stage.getScale();
|
||||
const { x, y } = this.manager.stage.getPosition();
|
||||
const { width, height } = this.manager.stage.getSize();
|
||||
const gridSpacing = CanvasBackgroundModule.getGridSpacing(scale);
|
||||
const gridSpacing = this.getGridSpacing(scale);
|
||||
const stageRect = {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
@@ -118,44 +92,47 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
|
||||
let _y = 0;
|
||||
|
||||
this.konva.layer.destroyChildren();
|
||||
this.konva.lines = [];
|
||||
|
||||
for (let i = 0; i < xSteps; i++) {
|
||||
_x = gridFullRect.x1 + i * gridSpacing;
|
||||
const line = new Konva.Line({
|
||||
x: _x,
|
||||
y: gridFullRect.y1,
|
||||
points: [0, 0, 0, ySize],
|
||||
stroke: _x % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE,
|
||||
strokeWidth,
|
||||
listening: false,
|
||||
});
|
||||
this.konva.lines.push(line);
|
||||
this.konva.layer.add(line);
|
||||
this.konva.layer.add(
|
||||
new Konva.Line({
|
||||
x: _x,
|
||||
y: gridFullRect.y1,
|
||||
points: [0, 0, 0, ySize],
|
||||
stroke: _x % 64 ? CanvasBackgroundModule.GRID_LINE_COLOR_FINE : CanvasBackgroundModule.GRID_LINE_COLOR_COARSE,
|
||||
strokeWidth,
|
||||
listening: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < ySteps; i++) {
|
||||
_y = gridFullRect.y1 + i * gridSpacing;
|
||||
const line = new Konva.Line({
|
||||
x: gridFullRect.x1,
|
||||
y: _y,
|
||||
points: [0, 0, xSize, 0],
|
||||
stroke: _y % 64 ? this.config.GRID_LINE_COLOR_FINE : this.config.GRID_LINE_COLOR_COARSE,
|
||||
strokeWidth,
|
||||
listening: false,
|
||||
});
|
||||
this.konva.lines.push(line);
|
||||
this.konva.layer.add(line);
|
||||
this.konva.layer.add(
|
||||
new Konva.Line({
|
||||
x: gridFullRect.x1,
|
||||
y: _y,
|
||||
points: [0, 0, xSize, 0],
|
||||
stroke: _y % 64 ? CanvasBackgroundModule.GRID_LINE_COLOR_FINE : CanvasBackgroundModule.GRID_LINE_COLOR_COARSE,
|
||||
strokeWidth,
|
||||
listening: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
destroy = () => {
|
||||
this.log.trace('Destroying background module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.konva.layer.destroy();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the grid line spacing for the dynamic grid.
|
||||
*
|
||||
* The value depends on the stage scale - at higher scales, the grid spacing is smaller.
|
||||
*
|
||||
* Gets the grid spacing. The value depends on the stage scale - at higher scales, the grid spacing is smaller.
|
||||
* @param scale The stage scale
|
||||
* @returns The grid spacing based on the stage scale
|
||||
*/
|
||||
static getGridSpacing = (scale: number): number => {
|
||||
getGridSpacing = (scale: number): number => {
|
||||
if (scale >= 2) {
|
||||
return 8;
|
||||
}
|
||||
@@ -174,9 +151,15 @@ export class CanvasBackgroundModule extends CanvasModuleBase {
|
||||
return 256;
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.trace('Destroying module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.konva.layer.destroy();
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
path: this.path,
|
||||
type: this.type,
|
||||
};
|
||||
};
|
||||
|
||||
getLoggingContext = () => {
|
||||
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { SerializableObject } from 'common/types';
|
||||
import { roundToMultiple, roundToMultipleMin } from 'common/util/roundDownToMultiple';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import type { CanvasPreviewModule } from 'features/controlLayers/konva/CanvasPreviewModule';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
|
||||
import type { Rect } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import { atom } from 'nanostores';
|
||||
import type { Logger } from 'roarr';
|
||||
@@ -21,57 +23,43 @@ const ALL_ANCHORS: string[] = [
|
||||
const CORNER_ANCHORS: string[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||||
const NO_ANCHORS: string[] = [];
|
||||
|
||||
/**
|
||||
* Renders the bounding box. The bounding box can be transformed by the user.
|
||||
*/
|
||||
export class CanvasBboxModule extends CanvasModuleBase {
|
||||
export class CanvasBboxModule extends CanvasModuleABC {
|
||||
readonly type = 'bbox';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
parent: CanvasManager;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
|
||||
subscriptions: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
* The Konva objects that make up the bbox:
|
||||
* - A group to hold all the objects
|
||||
* - A transformer to allow the bbox to be transformed
|
||||
* - A transparent rect so the transformer has something to transform
|
||||
*/
|
||||
parent: CanvasPreviewModule;
|
||||
|
||||
konva: {
|
||||
group: Konva.Group;
|
||||
rect: Konva.Rect;
|
||||
transformer: Konva.Transformer;
|
||||
proxyRect: Konva.Rect;
|
||||
};
|
||||
|
||||
/**
|
||||
* Buffer to store the last aspect ratio of the bbox. When the users holds shift while transforming the bbox, this is
|
||||
* used to lock the aspect ratio.
|
||||
*/
|
||||
$aspectRatioBuffer = atom(0);
|
||||
|
||||
constructor(manager: CanvasManager) {
|
||||
constructor(parent: CanvasPreviewModule) {
|
||||
super();
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.parent = manager;
|
||||
this.manager = manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.parent = parent;
|
||||
this.manager = this.parent.manager;
|
||||
this.path = this.parent.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
|
||||
this.log.debug('Creating bbox module');
|
||||
|
||||
// Set the initial aspect ratio buffer per app state.
|
||||
// Create a stash to hold onto the last aspect ratio of the bbox - this allows for locking the aspect ratio when
|
||||
// transforming the bbox.
|
||||
const bbox = this.manager.stateApi.getBbox();
|
||||
this.$aspectRatioBuffer.set(bbox.rect.width / bbox.rect.height);
|
||||
const $aspectRatioBuffer = atom(bbox.rect.width / bbox.rect.height);
|
||||
|
||||
this.konva = {
|
||||
group: new Konva.Group({ name: `${this.type}:group`, listening: true }),
|
||||
// We will use a Konva.Transformer for the generation bbox. Transformers need some shape to transform, so we will
|
||||
// create a transparent rect for this purpose.
|
||||
proxyRect: new Konva.Rect({
|
||||
group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
|
||||
// Use a transformer for the generation bbox. Transformers need some shape to transform, we will use a fully
|
||||
// transparent rect for this purpose.
|
||||
rect: new Konva.Rect({
|
||||
name: `${this.type}:rect`,
|
||||
listening: false,
|
||||
strokeEnabled: false,
|
||||
@@ -97,43 +85,171 @@ export class CanvasBboxModule extends CanvasModuleBase {
|
||||
anchorCornerRadius: 3,
|
||||
shiftBehavior: 'none', // we will implement our own shift behavior
|
||||
centeredScaling: false,
|
||||
anchorStyleFunc: this.anchorStyleFunc,
|
||||
anchorDragBoundFunc: this.anchorDragBoundFunc,
|
||||
anchorStyleFunc: (anchor) => {
|
||||
// Make the x/y resize anchors little bars
|
||||
if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) {
|
||||
anchor.height(8);
|
||||
anchor.offsetY(4);
|
||||
anchor.width(30);
|
||||
anchor.offsetX(15);
|
||||
}
|
||||
if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) {
|
||||
anchor.height(30);
|
||||
anchor.offsetY(15);
|
||||
anchor.width(8);
|
||||
anchor.offsetX(4);
|
||||
}
|
||||
},
|
||||
anchorDragBoundFunc: (_oldAbsPos, newAbsPos) => {
|
||||
// This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed
|
||||
// to konva's internal coordinate system.
|
||||
const stage = this.konva.transformer.getStage();
|
||||
assert(stage, 'Stage must exist');
|
||||
|
||||
// We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid.
|
||||
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
|
||||
// Because we are working in absolute coordinates, we need to scale the grid size by the stage scale.
|
||||
const scaledGridSize = gridSize * stage.scaleX();
|
||||
// To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position.
|
||||
const stageAbsPos = stage.getAbsolutePosition();
|
||||
// The offset is the remainder of the stage's absolute position divided by the scaled grid size.
|
||||
const offsetX = stageAbsPos.x % scaledGridSize;
|
||||
const offsetY = stageAbsPos.y % scaledGridSize;
|
||||
// Finally, calculate the position by rounding to the grid and adding the offset.
|
||||
return {
|
||||
x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX,
|
||||
y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
this.konva.rect.on('dragmove', () => {
|
||||
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
|
||||
const bbox = this.manager.stateApi.getBbox();
|
||||
const bboxRect: Rect = {
|
||||
...bbox.rect,
|
||||
x: roundToMultiple(this.konva.rect.x(), gridSize),
|
||||
y: roundToMultiple(this.konva.rect.y(), gridSize),
|
||||
};
|
||||
this.konva.rect.setAttrs(bboxRect);
|
||||
if (bbox.rect.x !== bboxRect.x || bbox.rect.y !== bboxRect.y) {
|
||||
this.manager.stateApi.setGenerationBbox(bboxRect);
|
||||
}
|
||||
});
|
||||
|
||||
this.konva.proxyRect.on('dragmove', this.onDragMove);
|
||||
this.konva.transformer.on('transform', this.onTransform);
|
||||
this.konva.transformer.on('transformend', this.onTransformEnd);
|
||||
this.konva.transformer.on('transform', () => {
|
||||
// In the transform callback, we calculate the bbox's new dims and pos and update the konva object.
|
||||
// Some special handling is needed depending on the anchor being dragged.
|
||||
const anchor = this.konva.transformer.getActiveAnchor();
|
||||
if (!anchor) {
|
||||
// Pretty sure we should always have an anchor here?
|
||||
return;
|
||||
}
|
||||
|
||||
// The transformer will always be transforming the proxy rect
|
||||
this.konva.transformer.nodes([this.konva.proxyRect]);
|
||||
this.konva.group.add(this.konva.proxyRect);
|
||||
const alt = this.manager.stateApi.$altKey.get();
|
||||
const ctrl = this.manager.stateApi.$ctrlKey.get();
|
||||
const meta = this.manager.stateApi.$metaKey.get();
|
||||
const shift = this.manager.stateApi.$shiftKey.get();
|
||||
|
||||
// Grid size depends on the modifier keys
|
||||
let gridSize = ctrl || meta ? 8 : 64;
|
||||
|
||||
// Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the
|
||||
// new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if
|
||||
// we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes.
|
||||
// Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid.
|
||||
if (this.manager.stateApi.$altKey.get()) {
|
||||
gridSize = gridSize * 2;
|
||||
}
|
||||
|
||||
// The coords should be correct per the anchorDragBoundFunc.
|
||||
let x = this.konva.rect.x();
|
||||
let y = this.konva.rect.y();
|
||||
|
||||
// Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height
|
||||
// *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap
|
||||
// them to the grid.
|
||||
let width = roundToMultipleMin(this.konva.rect.width() * this.konva.rect.scaleX(), gridSize);
|
||||
let height = roundToMultipleMin(this.konva.rect.height() * this.konva.rect.scaleY(), gridSize);
|
||||
|
||||
// If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this
|
||||
// if alt/opt is held - this requires math too big for my brain.
|
||||
if (shift && CORNER_ANCHORS.includes(anchor) && !alt) {
|
||||
// Fit the bbox to the last aspect ratio
|
||||
let fittedWidth = Math.sqrt(width * height * $aspectRatioBuffer.get());
|
||||
let fittedHeight = fittedWidth / $aspectRatioBuffer.get();
|
||||
fittedWidth = roundToMultipleMin(fittedWidth, gridSize);
|
||||
fittedHeight = roundToMultipleMin(fittedHeight, gridSize);
|
||||
|
||||
// We need to adjust the x and y coords to have the resize occur from the right origin.
|
||||
if (anchor === 'top-left') {
|
||||
// The transform origin is the bottom-right anchor. Both x and y need to be updated.
|
||||
x = x - (fittedWidth - width);
|
||||
y = y - (fittedHeight - height);
|
||||
}
|
||||
if (anchor === 'top-right') {
|
||||
// The transform origin is the bottom-left anchor. Only y needs to be updated.
|
||||
y = y - (fittedHeight - height);
|
||||
}
|
||||
if (anchor === 'bottom-left') {
|
||||
// The transform origin is the top-right anchor. Only x needs to be updated.
|
||||
x = x - (fittedWidth - width);
|
||||
}
|
||||
// Update the width and height to the fitted dims.
|
||||
width = fittedWidth;
|
||||
height = fittedHeight;
|
||||
}
|
||||
|
||||
const bboxRect = {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
// Update the bboxRect's attrs directly with the new transform, and reset its scale to 1.
|
||||
// TODO(psyche): In `renderBboxPreview()` we also call setAttrs, need to do it twice to ensure it renders correctly.
|
||||
// Gotta be a way to avoid setting it twice...
|
||||
this.konva.rect.setAttrs({ ...bboxRect, scaleX: 1, scaleY: 1 });
|
||||
|
||||
// Update the bbox in internal state.
|
||||
this.manager.stateApi.setGenerationBbox(bboxRect);
|
||||
|
||||
// Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start
|
||||
// a transform, get the right aspect ratio, then hold shift to lock it in.
|
||||
if (!shift) {
|
||||
$aspectRatioBuffer.set(bboxRect.width / bboxRect.height);
|
||||
}
|
||||
});
|
||||
|
||||
this.konva.transformer.on('transformend', () => {
|
||||
// Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held,
|
||||
// we have the correct aspect ratio to start from.
|
||||
$aspectRatioBuffer.set(this.konva.rect.width() / this.konva.rect.height());
|
||||
});
|
||||
|
||||
// The transformer will always be transforming the dummy rect
|
||||
this.konva.transformer.nodes([this.konva.rect]);
|
||||
this.konva.group.add(this.konva.rect);
|
||||
this.konva.group.add(this.konva.transformer);
|
||||
|
||||
// We will listen to the tool state to determine if the bbox should be visible or not.
|
||||
this.subscriptions.add(this.manager.tool.$tool.listen(this.render));
|
||||
this.subscriptions.add(this.manager.stateApi.$tool.listen(this.render));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the bbox. The bbox is only visible when the tool is set to 'bbox'.
|
||||
*/
|
||||
render = () => {
|
||||
this.log.trace('Rendering');
|
||||
this.log.trace('Rendering bbox module');
|
||||
|
||||
const { x, y, width, height } = this.manager.stateApi.getBbox().rect;
|
||||
const tool = this.manager.tool.$tool.get();
|
||||
const bbox = this.manager.stateApi.getBbox();
|
||||
const tool = this.manager.stateApi.$tool.get();
|
||||
|
||||
this.konva.group.visible(true);
|
||||
|
||||
// We need to reach up to the preview layer to enable/disable listening so that the bbox can be interacted with.
|
||||
this.manager.konva.previewLayer.listening(tool === 'bbox');
|
||||
|
||||
this.konva.proxyRect.setAttrs({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
this.parent.getLayer().listening(tool === 'bbox');
|
||||
this.konva.group.listening(tool === 'bbox');
|
||||
this.konva.rect.setAttrs({
|
||||
x: bbox.rect.x,
|
||||
y: bbox.rect.y,
|
||||
width: bbox.rect.width,
|
||||
height: bbox.rect.height,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
listening: tool === 'bbox',
|
||||
@@ -144,174 +260,21 @@ export class CanvasBboxModule extends CanvasModuleBase {
|
||||
});
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
};
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.trace('Destroying module');
|
||||
this.log.trace('Destroying bbox module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the dragmove event on the bbox rect:
|
||||
* - Snaps the bbox position to the grid (determined by ctrl/meta key)
|
||||
* - Pushes the new bbox rect into app state
|
||||
*/
|
||||
onDragMove = () => {
|
||||
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
|
||||
const bbox = this.manager.stateApi.getBbox();
|
||||
const bboxRect: Rect = {
|
||||
...bbox.rect,
|
||||
x: roundToMultiple(this.konva.proxyRect.x(), gridSize),
|
||||
y: roundToMultiple(this.konva.proxyRect.y(), gridSize),
|
||||
};
|
||||
this.konva.proxyRect.setAttrs(bboxRect);
|
||||
if (bbox.rect.x !== bboxRect.x || bbox.rect.y !== bboxRect.y) {
|
||||
this.manager.stateApi.setGenerationBbox(bboxRect);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the transform event on the bbox transformer:
|
||||
* - Snaps the bbox dimensions to the grid (determined by ctrl/meta key)
|
||||
* - Centered scaling when alt is held
|
||||
* - Aspect ratio locking when shift is held
|
||||
* - Pushes the new bbox rect into app state
|
||||
* - Syncs the aspect ratio buffer
|
||||
*/
|
||||
onTransform = () => {
|
||||
// In the transform callback, we calculate the bbox's new dims and pos and update the konva object.
|
||||
// Some special handling is needed depending on the anchor being dragged.
|
||||
const anchor = this.konva.transformer.getActiveAnchor();
|
||||
if (!anchor) {
|
||||
// Pretty sure we should always have an anchor here?
|
||||
return;
|
||||
}
|
||||
|
||||
const alt = this.manager.stateApi.$altKey.get();
|
||||
const ctrl = this.manager.stateApi.$ctrlKey.get();
|
||||
const meta = this.manager.stateApi.$metaKey.get();
|
||||
const shift = this.manager.stateApi.$shiftKey.get();
|
||||
|
||||
// Grid size depends on the modifier keys
|
||||
let gridSize = ctrl || meta ? 8 : 64;
|
||||
|
||||
// Alt key indicates we are using centered scaling. We need to double the gride size used when calculating the
|
||||
// new dimensions so that each size scales in the correct increments and doesn't mis-place the bbox. For example, if
|
||||
// we snapped the width and height to 8px increments, the bbox would be mis-placed by 4px in the x and y axes.
|
||||
// Doubling the grid size ensures the bbox's coords remain aligned to the 8px/64px grid.
|
||||
if (this.manager.stateApi.$altKey.get()) {
|
||||
gridSize = gridSize * 2;
|
||||
}
|
||||
|
||||
// The coords should be correct per the anchorDragBoundFunc.
|
||||
let x = this.konva.proxyRect.x();
|
||||
let y = this.konva.proxyRect.y();
|
||||
|
||||
// Konva transforms by scaling the dims, not directly changing width and height. At this point, the width and height
|
||||
// *have not changed*, only the scale has changed. To get the final height, we need to scale the dims and then snap
|
||||
// them to the grid.
|
||||
let width = roundToMultipleMin(this.konva.proxyRect.width() * this.konva.proxyRect.scaleX(), gridSize);
|
||||
let height = roundToMultipleMin(this.konva.proxyRect.height() * this.konva.proxyRect.scaleY(), gridSize);
|
||||
|
||||
// If shift is held and we are resizing from a corner, retain aspect ratio - needs special handling. We skip this
|
||||
// if alt/opt is held - this requires math too big for my brain.
|
||||
if (shift && CORNER_ANCHORS.includes(anchor) && !alt) {
|
||||
// Fit the bbox to the last aspect ratio
|
||||
let fittedWidth = Math.sqrt(width * height * this.$aspectRatioBuffer.get());
|
||||
let fittedHeight = fittedWidth / this.$aspectRatioBuffer.get();
|
||||
fittedWidth = roundToMultipleMin(fittedWidth, gridSize);
|
||||
fittedHeight = roundToMultipleMin(fittedHeight, gridSize);
|
||||
|
||||
// We need to adjust the x and y coords to have the resize occur from the right origin.
|
||||
if (anchor === 'top-left') {
|
||||
// The transform origin is the bottom-right anchor. Both x and y need to be updated.
|
||||
x = x - (fittedWidth - width);
|
||||
y = y - (fittedHeight - height);
|
||||
}
|
||||
if (anchor === 'top-right') {
|
||||
// The transform origin is the bottom-left anchor. Only y needs to be updated.
|
||||
y = y - (fittedHeight - height);
|
||||
}
|
||||
if (anchor === 'bottom-left') {
|
||||
// The transform origin is the top-right anchor. Only x needs to be updated.
|
||||
x = x - (fittedWidth - width);
|
||||
}
|
||||
// Update the width and height to the fitted dims.
|
||||
width = fittedWidth;
|
||||
height = fittedHeight;
|
||||
}
|
||||
|
||||
const bboxRect = {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
|
||||
// Update the bboxRect's attrs directly with the new transform, and reset its scale to 1.
|
||||
this.konva.proxyRect.setAttrs({ ...bboxRect, scaleX: 1, scaleY: 1 });
|
||||
|
||||
// Update the bbox in internal state.
|
||||
this.manager.stateApi.setGenerationBbox(bboxRect);
|
||||
|
||||
// Update the aspect ratio buffer whenever the shift key is not held - this allows for a nice UX where you can start
|
||||
// a transform, get the right aspect ratio, then hold shift to lock it in.
|
||||
if (!shift) {
|
||||
this.$aspectRatioBuffer.set(bboxRect.width / bboxRect.height);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the transformend event on the bbox transformer:
|
||||
* - Updates the aspect ratio buffer with the new aspect ratio
|
||||
*/
|
||||
onTransformEnd = () => {
|
||||
// Always update the aspect ratio buffer when the transform ends, so if the next transform starts with shift held,
|
||||
// we have the correct aspect ratio to start from.
|
||||
this.$aspectRatioBuffer.set(this.konva.proxyRect.width() / this.konva.proxyRect.height());
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is called for each anchor on the transformer. It sets the style of the anchor based on its name.
|
||||
* We make the x/y resize anchors little bars.
|
||||
*/
|
||||
anchorStyleFunc = (anchor: Konva.Rect): void => {
|
||||
if (anchor.hasName('top-center') || anchor.hasName('bottom-center')) {
|
||||
anchor.height(8);
|
||||
anchor.offsetY(4);
|
||||
anchor.width(30);
|
||||
anchor.offsetX(15);
|
||||
}
|
||||
if (anchor.hasName('middle-left') || anchor.hasName('middle-right')) {
|
||||
anchor.height(30);
|
||||
anchor.offsetY(15);
|
||||
anchor.width(8);
|
||||
anchor.offsetX(4);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is called for each anchor on the transformer. It sets the drag bounds for the anchor based on the
|
||||
* stage's position and the grid size. Care is taken to ensure the anchor snaps to the grid correctly.
|
||||
*/
|
||||
anchorDragBoundFunc = (oldAbsPos: Coordinate, newAbsPos: Coordinate): Coordinate => {
|
||||
// This function works with absolute position - that is, a position in "physical" pixels on the screen, as opposed
|
||||
// to konva's internal coordinate system.
|
||||
const stage = this.konva.transformer.getStage();
|
||||
assert(stage, 'Stage must exist');
|
||||
|
||||
// We need to snap the anchors to the grid. If the user is holding ctrl/meta, we use the finer 8px grid.
|
||||
const gridSize = this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() ? 8 : 64;
|
||||
// Because we are working in absolute coordinates, we need to scale the grid size by the stage scale.
|
||||
const scaledGridSize = gridSize * stage.scaleX();
|
||||
// To snap the anchor to the grid, we need to calculate an offset from the stage's absolute position.
|
||||
const stageAbsPos = stage.getAbsolutePosition();
|
||||
// The offset is the remainder of the stage's absolute position divided by the scaled grid size.
|
||||
const offsetX = stageAbsPos.x % scaledGridSize;
|
||||
const offsetY = stageAbsPos.y % scaledGridSize;
|
||||
// Finally, calculate the position by rounding to the grid and adding the offset.
|
||||
return {
|
||||
x: roundToMultiple(newAbsPos.x, scaledGridSize) + offsetX,
|
||||
y: roundToMultiple(newAbsPos.y, scaledGridSize) + offsetY,
|
||||
};
|
||||
getLoggingContext = (): SerializableObject => {
|
||||
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { rgbaColorToString } from 'common/util/colorCodeTransformers';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
|
||||
import { alignCoordForTool, getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import Konva from 'konva';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
type BrushToolPreviewConfig = {
|
||||
/**
|
||||
* The inner border color for the brush tool preview.
|
||||
*/
|
||||
BORDER_INNER_COLOR: string;
|
||||
/**
|
||||
* The outer border color for the brush tool preview.
|
||||
*/
|
||||
BORDER_OUTER_COLOR: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: BrushToolPreviewConfig = {
|
||||
BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
|
||||
BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a preview of the brush tool on the canvas.
|
||||
*/
|
||||
export class CanvasBrushToolPreview extends CanvasModuleBase {
|
||||
readonly type = 'brush_tool_preview';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
parent: CanvasToolModule;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
|
||||
config: BrushToolPreviewConfig = DEFAULT_CONFIG;
|
||||
|
||||
/**
|
||||
* The Konva objects that make up the brush tool preview:
|
||||
* - A group to hold the fill circle and borders
|
||||
* - A circle to fill the brush area
|
||||
* - An inner border ring
|
||||
* - An outer border ring
|
||||
*/
|
||||
konva: {
|
||||
group: Konva.Group;
|
||||
fillCircle: Konva.Circle;
|
||||
innerBorder: Konva.Ring;
|
||||
outerBorder: Konva.Ring;
|
||||
};
|
||||
|
||||
constructor(parent: CanvasToolModule) {
|
||||
super();
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.parent = parent;
|
||||
this.manager = this.parent.manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
|
||||
this.log.debug('Creating module');
|
||||
|
||||
this.konva = {
|
||||
group: new Konva.Group({ name: `${this.type}:brush_group`, listening: false }),
|
||||
fillCircle: new Konva.Circle({
|
||||
name: `${this.type}:brush_fill_circle`,
|
||||
listening: false,
|
||||
strokeEnabled: false,
|
||||
}),
|
||||
innerBorder: new Konva.Ring({
|
||||
name: `${this.type}:brush_inner_border_ring`,
|
||||
listening: false,
|
||||
innerRadius: 0,
|
||||
outerRadius: 0,
|
||||
fill: this.config.BORDER_INNER_COLOR,
|
||||
strokeEnabled: false,
|
||||
}),
|
||||
outerBorder: new Konva.Ring({
|
||||
name: `${this.type}:brush_outer_border_ring`,
|
||||
listening: false,
|
||||
innerRadius: 0,
|
||||
outerRadius: 0,
|
||||
fill: this.config.BORDER_OUTER_COLOR,
|
||||
strokeEnabled: false,
|
||||
}),
|
||||
};
|
||||
this.konva.group.add(this.konva.fillCircle, this.konva.innerBorder, this.konva.outerBorder);
|
||||
}
|
||||
|
||||
render = () => {
|
||||
const cursorPos = this.manager.tool.$lastCursorPos.get();
|
||||
|
||||
// If the cursor position is not available, do not update the brush preview. The tool module will handle visiblity.
|
||||
if (!cursorPos) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolState = this.manager.stateApi.getToolState();
|
||||
const brushPreviewFill = this.manager.stateApi.getBrushPreviewFill();
|
||||
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
|
||||
const radius = toolState.brush.width / 2;
|
||||
|
||||
// The circle is scaled
|
||||
this.konva.fillCircle.setAttrs({
|
||||
x: alignedCursorPos.x,
|
||||
y: alignedCursorPos.y,
|
||||
radius,
|
||||
fill: rgbaColorToString(brushPreviewFill),
|
||||
});
|
||||
|
||||
// But the borders are in screen-pixels
|
||||
const onePixel = this.manager.stage.getScaledPixels(1);
|
||||
const twoPixels = this.manager.stage.getScaledPixels(2);
|
||||
|
||||
this.konva.innerBorder.setAttrs({
|
||||
x: cursorPos.x,
|
||||
y: cursorPos.y,
|
||||
innerRadius: radius,
|
||||
outerRadius: radius + onePixel,
|
||||
});
|
||||
this.konva.outerBorder.setAttrs({
|
||||
x: cursorPos.x,
|
||||
y: cursorPos.y,
|
||||
innerRadius: radius + onePixel,
|
||||
outerRadius: radius + twoPixels,
|
||||
});
|
||||
};
|
||||
|
||||
setVisibility = (visible: boolean) => {
|
||||
this.konva.group.visible(visible);
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
config: this.config,
|
||||
};
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying module');
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
}
|
||||
@@ -1,92 +1,54 @@
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import type { GenerationMode } from 'features/controlLayers/store/types';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
type CanvasCacheModuleConfig = {
|
||||
/**
|
||||
* The maximum size of the image name cache.
|
||||
*/
|
||||
imageNameCacheSize: number;
|
||||
/**
|
||||
* The maximum size of the canvas element cache.
|
||||
*/
|
||||
canvasElementCacheSize: number;
|
||||
/**
|
||||
* The maximum size of the generation mode cache.
|
||||
*/
|
||||
generationModeCacheSize: number;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: CanvasCacheModuleConfig = {
|
||||
imageNameCacheSize: 100,
|
||||
canvasElementCacheSize: 32,
|
||||
generationModeCacheSize: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* A cache module for storing the results of expensive calculations. For example, when we rasterize a layer and upload
|
||||
* it to the server, we store the resultant image name in this cache for future use.
|
||||
*/
|
||||
export class CanvasCacheModule extends CanvasModuleBase {
|
||||
export class CanvasCacheModule extends CanvasModuleABC {
|
||||
readonly type = 'cache';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
log: Logger;
|
||||
parent: CanvasManager;
|
||||
manager: CanvasManager;
|
||||
subscriptions = new Set<() => void>();
|
||||
|
||||
config: CanvasCacheModuleConfig = DEFAULT_CONFIG;
|
||||
|
||||
/**
|
||||
* A cache for storing image names. Used as a cache for results of layer/canvas/entity exports. For example, when we
|
||||
* rasterize a layer and upload it to the server, we store the image name in this cache.
|
||||
*
|
||||
* The cache key is a hash of the exported entity's state and the export rect.
|
||||
*/
|
||||
imageNameCache = new LRUCache<string, string>({ max: this.config.imageNameCacheSize });
|
||||
|
||||
/**
|
||||
* A cache for storing canvas elements. Similar to the image name cache, but for canvas elements. The primary use is
|
||||
* for caching composite layers. For example, the canvas compositor module uses this to store the canvas elements for
|
||||
* individual raster layers when creating a composite of the layers.
|
||||
*
|
||||
* The cache key is a hash of the exported entity's state and the export rect.
|
||||
*/
|
||||
canvasElementCache = new LRUCache<string, HTMLCanvasElement>({ max: this.config.canvasElementCacheSize });
|
||||
/**
|
||||
* A cache for the generation mode calculation, which is fairly expensive.
|
||||
*
|
||||
* The cache key is a hash of all the objects that contribute to the generation mode calculation (e.g. the composite
|
||||
* raster layer, the composite inpaint mask, and bounding box), and the value is the generation mode.
|
||||
*/
|
||||
generationModeCache = new LRUCache<string, GenerationMode>({ max: this.config.generationModeCacheSize });
|
||||
imageNameCache = new LRUCache<string, string>({ max: 100 });
|
||||
canvasElementCache = new LRUCache<string, HTMLCanvasElement>({ max: 32 });
|
||||
generationModeCache = new LRUCache<string, GenerationMode>({ max: 100 });
|
||||
|
||||
constructor(manager: CanvasManager) {
|
||||
super();
|
||||
this.id = getPrefixedId('cache');
|
||||
this.manager = manager;
|
||||
this.parent = manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.path = this.manager.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
|
||||
this.log.debug('Creating cache module');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all caches.
|
||||
*/
|
||||
clearAll = () => {
|
||||
this.canvasElementCache.clear();
|
||||
this.imageNameCache.clear();
|
||||
this.generationModeCache.clear();
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
path: this.path,
|
||||
type: this.type,
|
||||
};
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying cache module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.clearAll();
|
||||
};
|
||||
|
||||
getLoggingContext = () => {
|
||||
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import Konva from 'konva';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
type ColorPickerToolConfig = {
|
||||
/**
|
||||
* The inner radius of the ring.
|
||||
*/
|
||||
RING_INNER_RADIUS: number;
|
||||
/**
|
||||
* The outer radius of the ring.
|
||||
*/
|
||||
RING_OUTER_RADIUS: number;
|
||||
/**
|
||||
* The inner border color of the outside edge of ring.
|
||||
*/
|
||||
RING_BORDER_INNER_COLOR: string;
|
||||
/**
|
||||
* The outer border color of the outside edge of ring.
|
||||
*/
|
||||
RING_BORDER_OUTER_COLOR: string;
|
||||
|
||||
/**
|
||||
* The radius of the space between the center of the ring and start of the crosshair lines.
|
||||
*/
|
||||
CROSSHAIR_INNER_RADIUS: number;
|
||||
/**
|
||||
* The length of the crosshair lines.
|
||||
*/
|
||||
CROSSHAIR_LINE_LENGTH: number;
|
||||
/**
|
||||
* The thickness of the crosshair lines.
|
||||
*/
|
||||
CROSSHAIR_LINE_THICKNESS: number;
|
||||
/**
|
||||
* The color of the crosshair lines.
|
||||
*/
|
||||
CROSSHAIR_LINE_COLOR: string;
|
||||
/**
|
||||
* The thickness of the crosshair lines borders
|
||||
*/
|
||||
CROSSHAIR_LINE_BORDER_THICKNESS: number;
|
||||
/**
|
||||
* The color of the crosshair line borders.
|
||||
*/
|
||||
CROSSHAIR_BORDER_COLOR: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: ColorPickerToolConfig = {
|
||||
RING_INNER_RADIUS: 25,
|
||||
RING_OUTER_RADIUS: 35,
|
||||
RING_BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
|
||||
RING_BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
|
||||
CROSSHAIR_INNER_RADIUS: 5,
|
||||
CROSSHAIR_LINE_THICKNESS: 1.5,
|
||||
CROSSHAIR_LINE_BORDER_THICKNESS: 0.75,
|
||||
CROSSHAIR_LINE_LENGTH: 10,
|
||||
CROSSHAIR_LINE_COLOR: 'rgba(0,0,0,1)',
|
||||
CROSSHAIR_BORDER_COLOR: 'rgba(255,255,255,0.8)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a preview of the color picker tool on the canvas.
|
||||
*/
|
||||
export class CanvasColorPickerToolPreview extends CanvasModuleBase {
|
||||
readonly type = 'color_picker_tool_preview';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
parent: CanvasToolModule;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
|
||||
config: ColorPickerToolConfig = DEFAULT_CONFIG;
|
||||
|
||||
/**
|
||||
* The Konva objects that make up the color picker tool preview:
|
||||
* - A group to hold all the objects
|
||||
* - A ring that shows the candidate and current color
|
||||
* - A crosshair to help with color selection
|
||||
*/
|
||||
konva: {
|
||||
group: Konva.Group;
|
||||
ringCandidateColor: Konva.Ring;
|
||||
ringCurrentColor: Konva.Arc;
|
||||
ringInnerBorder: Konva.Ring;
|
||||
ringOuterBorder: Konva.Ring;
|
||||
crosshairNorthInner: Konva.Line;
|
||||
crosshairNorthOuter: Konva.Line;
|
||||
crosshairEastInner: Konva.Line;
|
||||
crosshairEastOuter: Konva.Line;
|
||||
crosshairSouthInner: Konva.Line;
|
||||
crosshairSouthOuter: Konva.Line;
|
||||
crosshairWestInner: Konva.Line;
|
||||
crosshairWestOuter: Konva.Line;
|
||||
};
|
||||
|
||||
constructor(parent: CanvasToolModule) {
|
||||
super();
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.parent = parent;
|
||||
this.manager = this.parent.manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
|
||||
this.log.debug('Creating module');
|
||||
|
||||
this.konva = {
|
||||
group: new Konva.Group({ name: `${this.type}:color_picker_group`, listening: false }),
|
||||
ringCandidateColor: new Konva.Ring({
|
||||
name: `${this.type}:color_picker_candidate_color_ring`,
|
||||
innerRadius: 0,
|
||||
outerRadius: 0,
|
||||
strokeEnabled: false,
|
||||
}),
|
||||
ringCurrentColor: new Konva.Arc({
|
||||
name: `${this.type}:color_picker_current_color_arc`,
|
||||
innerRadius: 0,
|
||||
outerRadius: 0,
|
||||
angle: 180,
|
||||
strokeEnabled: false,
|
||||
}),
|
||||
ringInnerBorder: new Konva.Ring({
|
||||
name: `${this.type}:color_picker_inner_border_ring`,
|
||||
innerRadius: 0,
|
||||
outerRadius: 0,
|
||||
fill: this.config.RING_BORDER_INNER_COLOR,
|
||||
strokeEnabled: false,
|
||||
}),
|
||||
ringOuterBorder: new Konva.Ring({
|
||||
name: `${this.type}:color_picker_outer_border_ring`,
|
||||
innerRadius: 0,
|
||||
outerRadius: 0,
|
||||
fill: this.config.RING_BORDER_OUTER_COLOR,
|
||||
strokeEnabled: false,
|
||||
}),
|
||||
crosshairNorthInner: new Konva.Line({
|
||||
name: `${this.type}:color_picker_crosshair_north1_line`,
|
||||
stroke: this.config.CROSSHAIR_LINE_COLOR,
|
||||
}),
|
||||
crosshairNorthOuter: new Konva.Line({
|
||||
name: `${this.type}:color_picker_crosshair_north2_line`,
|
||||
stroke: this.config.CROSSHAIR_BORDER_COLOR,
|
||||
}),
|
||||
crosshairEastInner: new Konva.Line({
|
||||
name: `${this.type}:color_picker_crosshair_east1_line`,
|
||||
stroke: this.config.CROSSHAIR_LINE_COLOR,
|
||||
}),
|
||||
crosshairEastOuter: new Konva.Line({
|
||||
name: `${this.type}:color_picker_crosshair_east2_line`,
|
||||
stroke: this.config.CROSSHAIR_BORDER_COLOR,
|
||||
}),
|
||||
crosshairSouthInner: new Konva.Line({
|
||||
name: `${this.type}:color_picker_crosshair_south1_line`,
|
||||
stroke: this.config.CROSSHAIR_LINE_COLOR,
|
||||
}),
|
||||
crosshairSouthOuter: new Konva.Line({
|
||||
name: `${this.type}:color_picker_crosshair_south2_line`,
|
||||
stroke: this.config.CROSSHAIR_BORDER_COLOR,
|
||||
}),
|
||||
crosshairWestInner: new Konva.Line({
|
||||
name: `${this.type}:color_picker_crosshair_west1_line`,
|
||||
stroke: this.config.CROSSHAIR_LINE_COLOR,
|
||||
}),
|
||||
crosshairWestOuter: new Konva.Line({
|
||||
name: `${this.type}:color_picker_crosshair_west2_line`,
|
||||
stroke: this.config.CROSSHAIR_BORDER_COLOR,
|
||||
}),
|
||||
};
|
||||
|
||||
this.konva.group.add(
|
||||
this.konva.ringCandidateColor,
|
||||
this.konva.ringCurrentColor,
|
||||
this.konva.ringInnerBorder,
|
||||
this.konva.ringOuterBorder,
|
||||
this.konva.crosshairNorthOuter,
|
||||
this.konva.crosshairNorthInner,
|
||||
this.konva.crosshairEastOuter,
|
||||
this.konva.crosshairEastInner,
|
||||
this.konva.crosshairSouthOuter,
|
||||
this.konva.crosshairSouthInner,
|
||||
this.konva.crosshairWestOuter,
|
||||
this.konva.crosshairWestInner
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the color picker tool preview on the canvas.
|
||||
*/
|
||||
render = () => {
|
||||
const cursorPos = this.manager.tool.$lastCursorPos.get();
|
||||
|
||||
// If the cursor position is not available, do not render the preview. The tool module will handle visibility.
|
||||
if (!cursorPos) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolState = this.manager.stateApi.getToolState();
|
||||
const colorUnderCursor = this.parent.$colorUnderCursor.get();
|
||||
const colorPickerInnerRadius = this.manager.stage.getScaledPixels(this.config.RING_INNER_RADIUS);
|
||||
const colorPickerOuterRadius = this.manager.stage.getScaledPixels(this.config.RING_OUTER_RADIUS);
|
||||
const onePixel = this.manager.stage.getScaledPixels(1);
|
||||
const twoPixels = this.manager.stage.getScaledPixels(2);
|
||||
|
||||
this.konva.ringCandidateColor.setAttrs({
|
||||
x: cursorPos.x,
|
||||
y: cursorPos.y,
|
||||
fill: rgbColorToString(colorUnderCursor),
|
||||
innerRadius: colorPickerInnerRadius,
|
||||
outerRadius: colorPickerOuterRadius,
|
||||
});
|
||||
this.konva.ringCurrentColor.setAttrs({
|
||||
x: cursorPos.x,
|
||||
y: cursorPos.y,
|
||||
fill: rgbColorToString(toolState.fill),
|
||||
innerRadius: colorPickerInnerRadius,
|
||||
outerRadius: colorPickerOuterRadius,
|
||||
});
|
||||
this.konva.ringInnerBorder.setAttrs({
|
||||
x: cursorPos.x,
|
||||
y: cursorPos.y,
|
||||
innerRadius: colorPickerOuterRadius,
|
||||
outerRadius: colorPickerOuterRadius + onePixel,
|
||||
});
|
||||
this.konva.ringOuterBorder.setAttrs({
|
||||
x: cursorPos.x,
|
||||
y: cursorPos.y,
|
||||
innerRadius: colorPickerOuterRadius + onePixel,
|
||||
outerRadius: colorPickerOuterRadius + twoPixels,
|
||||
});
|
||||
|
||||
const size = this.manager.stage.getScaledPixels(this.config.CROSSHAIR_LINE_LENGTH);
|
||||
const space = this.manager.stage.getScaledPixels(this.config.CROSSHAIR_INNER_RADIUS);
|
||||
const innerThickness = this.manager.stage.getScaledPixels(this.config.CROSSHAIR_LINE_THICKNESS);
|
||||
const outerThickness = this.manager.stage.getScaledPixels(
|
||||
this.config.CROSSHAIR_LINE_THICKNESS + this.config.CROSSHAIR_LINE_BORDER_THICKNESS * 2
|
||||
);
|
||||
this.konva.crosshairNorthOuter.setAttrs({
|
||||
strokeWidth: outerThickness,
|
||||
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
|
||||
});
|
||||
this.konva.crosshairNorthInner.setAttrs({
|
||||
strokeWidth: innerThickness,
|
||||
points: [cursorPos.x, cursorPos.y - size, cursorPos.x, cursorPos.y - space],
|
||||
});
|
||||
this.konva.crosshairEastOuter.setAttrs({
|
||||
strokeWidth: outerThickness,
|
||||
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
|
||||
});
|
||||
this.konva.crosshairEastInner.setAttrs({
|
||||
strokeWidth: innerThickness,
|
||||
points: [cursorPos.x + space, cursorPos.y, cursorPos.x + size, cursorPos.y],
|
||||
});
|
||||
this.konva.crosshairSouthOuter.setAttrs({
|
||||
strokeWidth: outerThickness,
|
||||
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
|
||||
});
|
||||
this.konva.crosshairSouthInner.setAttrs({
|
||||
strokeWidth: innerThickness,
|
||||
points: [cursorPos.x, cursorPos.y + space, cursorPos.x, cursorPos.y + size],
|
||||
});
|
||||
this.konva.crosshairWestOuter.setAttrs({
|
||||
strokeWidth: outerThickness,
|
||||
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
|
||||
});
|
||||
this.konva.crosshairWestInner.setAttrs({
|
||||
strokeWidth: innerThickness,
|
||||
points: [cursorPos.x - space, cursorPos.y, cursorPos.x - size, cursorPos.y],
|
||||
});
|
||||
};
|
||||
|
||||
setVisibility = (visible: boolean) => {
|
||||
this.konva.group.visible(visible);
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
config: this.config,
|
||||
};
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying color picker tool preview module');
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SerializableObject } from 'common/types';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import {
|
||||
canvasToBlob,
|
||||
canvasToImageData,
|
||||
@@ -15,36 +15,24 @@ import type { ImageDTO } from 'services/api/types';
|
||||
import stableHash from 'stable-hash';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
/**
|
||||
* Handles compositing operations:
|
||||
* - Rasterizing and uploading the composite raster layer
|
||||
* - Rasterizing and uploading the composite inpaint mask
|
||||
* - Caclulating the generation mode (which requires the composite raster layer and inpaint mask)
|
||||
*/
|
||||
export class CanvasCompositorModule extends CanvasModuleBase {
|
||||
export class CanvasCompositorModule extends CanvasModuleABC {
|
||||
readonly type = 'compositor';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
log: Logger;
|
||||
parent: CanvasManager;
|
||||
manager: CanvasManager;
|
||||
subscriptions = new Set<() => void>();
|
||||
|
||||
constructor(manager: CanvasManager) {
|
||||
super();
|
||||
this.id = getPrefixedId('canvas_compositor');
|
||||
this.parent = manager;
|
||||
this.manager = manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.path = this.manager.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
this.log.debug('Creating compositor module');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the entity IDs of all raster layers that should be included in the composite raster layer.
|
||||
* A raster layer is included if it is enabled and has objects.
|
||||
* @returns An array of raster layer entity IDs
|
||||
*/
|
||||
getCompositeRasterLayerEntityIds = (): string[] => {
|
||||
const ids = [];
|
||||
for (const adapter of this.manager.adapters.rasterLayers.values()) {
|
||||
@@ -55,35 +43,16 @@ export class CanvasCompositorModule extends CanvasModuleBase {
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a hash of the composite raster layer, which includes the state of all raster layers that are included in the
|
||||
* composite plus arbitrary extra data that should contribute to the hash (e.g. a rect).
|
||||
* @param extra Any extra data to include in the hash
|
||||
* @returns A hash for the composite raster layer
|
||||
*/
|
||||
getCompositeRasterLayerHash = (extra: SerializableObject): string => {
|
||||
const data: Record<string, SerializableObject> = {
|
||||
extra,
|
||||
};
|
||||
for (const id of this.getCompositeRasterLayerEntityIds()) {
|
||||
const adapter = this.manager.adapters.rasterLayers.get(id);
|
||||
if (!adapter) {
|
||||
this.log.warn({ id }, 'Raster layer adapter not found');
|
||||
continue;
|
||||
getCompositeInpaintMaskEntityIds = (): string[] => {
|
||||
const ids = [];
|
||||
for (const adapter of this.manager.adapters.inpaintMasks.values()) {
|
||||
if (adapter.state.isEnabled && adapter.renderer.hasObjects()) {
|
||||
ids.push(adapter.id);
|
||||
}
|
||||
data[id] = adapter.getHashableState();
|
||||
}
|
||||
return stableHash(data);
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a canvas element for the composite raster layer. Only the region defined by the rect is included in the canvas.
|
||||
*
|
||||
* If the hash of the composite raster layer is found in the cache, the cached canvas is returned.
|
||||
*
|
||||
* @param rect The region to include in the canvas
|
||||
* @returns A canvas element with the composite raster layer drawn on it
|
||||
*/
|
||||
getCompositeRasterLayerCanvas = (rect: Rect): HTMLCanvasElement => {
|
||||
const hash = this.getCompositeRasterLayerHash({ rect });
|
||||
const cachedCanvas = this.manager.cache.canvasElementCache.get(hash);
|
||||
@@ -116,99 +85,6 @@ export class CanvasCompositorModule extends CanvasModuleBase {
|
||||
return canvas;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rasterizes the composite raster layer and uploads it to the server.
|
||||
*
|
||||
* If the hash of the composite raster layer is found in the cache, the cached image DTO is returned.
|
||||
*
|
||||
* @param rect The region to include in the rasterized image
|
||||
* @param saveToGallery Whether to save the image to the gallery or just return the uploaded image DTO
|
||||
* @returns A promise that resolves to the uploaded image DTO
|
||||
*/
|
||||
rasterizeAndUploadCompositeRasterLayer = async (rect: Rect, saveToGallery: boolean): Promise<ImageDTO> => {
|
||||
this.log.trace({ rect }, 'Rasterizing composite raster layer');
|
||||
|
||||
const canvas = this.getCompositeRasterLayerCanvas(rect);
|
||||
const blob = await canvasToBlob(canvas);
|
||||
|
||||
if (this.manager._isDebugging) {
|
||||
previewBlob(blob, 'Composite raster layer canvas');
|
||||
}
|
||||
|
||||
return uploadImage(blob, 'composite-raster-layer.png', 'general', !saveToGallery);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the image DTO for the composite raster layer.
|
||||
*
|
||||
* If the image is found in the cache, the cached image DTO is returned.
|
||||
*
|
||||
* @param rect The region to include in the image
|
||||
* @returns A promise that resolves to the image DTO
|
||||
*/
|
||||
getCompositeRasterLayerImageDTO = async (rect: Rect): Promise<ImageDTO> => {
|
||||
let imageDTO: ImageDTO | null = null;
|
||||
|
||||
const hash = this.getCompositeRasterLayerHash({ rect });
|
||||
const cachedImageName = this.manager.cache.imageNameCache.get(hash);
|
||||
|
||||
if (cachedImageName) {
|
||||
imageDTO = await getImageDTO(cachedImageName);
|
||||
if (imageDTO) {
|
||||
this.log.trace({ rect, imageName: cachedImageName, imageDTO }, 'Using cached composite raster layer image');
|
||||
return imageDTO;
|
||||
}
|
||||
}
|
||||
|
||||
imageDTO = await this.rasterizeAndUploadCompositeRasterLayer(rect, false);
|
||||
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
|
||||
return imageDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the entity IDs of all inpaint masks that should be included in the composite inpaint mask.
|
||||
* An inpaint mask is included if it is enabled and has objects.
|
||||
* @returns An array of inpaint mask entity IDs
|
||||
*/
|
||||
getCompositeInpaintMaskEntityIds = (): string[] => {
|
||||
const ids = [];
|
||||
for (const adapter of this.manager.adapters.inpaintMasks.values()) {
|
||||
if (adapter.state.isEnabled && adapter.renderer.hasObjects()) {
|
||||
ids.push(adapter.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a hash of the composite inpaint mask, which includes the state of all inpaint masks that are included in the
|
||||
* composite plus arbitrary extra data that should contribute to the hash (e.g. a rect).
|
||||
* @param extra Any extra data to include in the hash
|
||||
* @returns A hash for the composite inpaint mask
|
||||
*/
|
||||
getCompositeInpaintMaskHash = (extra: SerializableObject): string => {
|
||||
const data: Record<string, SerializableObject> = {
|
||||
extra,
|
||||
};
|
||||
for (const id of this.getCompositeInpaintMaskEntityIds()) {
|
||||
const adapter = this.manager.adapters.inpaintMasks.get(id);
|
||||
if (!adapter) {
|
||||
this.log.warn({ id }, 'Inpaint mask adapter not found');
|
||||
continue;
|
||||
}
|
||||
data[id] = adapter.getHashableState();
|
||||
}
|
||||
return stableHash(data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a canvas element for the composite inpaint mask. Only the region defined by the rect is included in the canvas.
|
||||
*
|
||||
* If the hash of the composite inpaint mask is found in the cache, the cached canvas is returned.
|
||||
*
|
||||
* @param rect The region to include in the canvas
|
||||
* @returns A canvas element with the composite inpaint mask drawn on it
|
||||
*/
|
||||
getCompositeInpaintMaskCanvas = (rect: Rect): HTMLCanvasElement => {
|
||||
const hash = this.getCompositeInpaintMaskHash({ rect });
|
||||
const cachedCanvas = this.manager.cache.canvasElementCache.get(hash);
|
||||
@@ -241,35 +117,63 @@ export class CanvasCompositorModule extends CanvasModuleBase {
|
||||
return canvas;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rasterizes the composite inpaint mask and uploads it to the server.
|
||||
*
|
||||
* If the hash of the composite inpaint mask is found in the cache, the cached image DTO is returned.
|
||||
*
|
||||
* @param rect The region to include in the rasterized image
|
||||
* @param saveToGallery Whether to save the image to the gallery or just return the uploaded image DTO
|
||||
* @returns A promise that resolves to the uploaded image DTO
|
||||
*/
|
||||
rasterizeAndUploadCompositeInpaintMask = async (rect: Rect, saveToGallery: boolean) => {
|
||||
this.log.trace({ rect }, 'Rasterizing composite inpaint mask');
|
||||
|
||||
const canvas = this.getCompositeInpaintMaskCanvas(rect);
|
||||
const blob = await canvasToBlob(canvas);
|
||||
if (this.manager._isDebugging) {
|
||||
previewBlob(blob, 'Composite inpaint mask canvas');
|
||||
getCompositeRasterLayerHash = (extra: SerializableObject): string => {
|
||||
const data: Record<string, SerializableObject> = {
|
||||
extra,
|
||||
};
|
||||
for (const id of this.getCompositeRasterLayerEntityIds()) {
|
||||
const adapter = this.manager.adapters.rasterLayers.get(id);
|
||||
if (!adapter) {
|
||||
this.log.warn({ id }, 'Raster layer adapter not found');
|
||||
continue;
|
||||
}
|
||||
data[id] = adapter.getHashableState();
|
||||
}
|
||||
|
||||
return uploadImage(blob, 'composite-inpaint-mask.png', 'general', !saveToGallery);
|
||||
return stableHash(data);
|
||||
};
|
||||
|
||||
getCompositeInpaintMaskHash = (extra: SerializableObject): string => {
|
||||
const data: Record<string, SerializableObject> = {
|
||||
extra,
|
||||
};
|
||||
for (const id of this.getCompositeInpaintMaskEntityIds()) {
|
||||
const adapter = this.manager.adapters.inpaintMasks.get(id);
|
||||
if (!adapter) {
|
||||
this.log.warn({ id }, 'Inpaint mask adapter not found');
|
||||
continue;
|
||||
}
|
||||
data[id] = adapter.getHashableState();
|
||||
}
|
||||
return stableHash(data);
|
||||
};
|
||||
|
||||
getCompositeRasterLayerImageDTO = async (rect: Rect): Promise<ImageDTO> => {
|
||||
let imageDTO: ImageDTO | null = null;
|
||||
|
||||
const hash = this.getCompositeRasterLayerHash({ rect });
|
||||
const cachedImageName = this.manager.cache.imageNameCache.get(hash);
|
||||
|
||||
if (cachedImageName) {
|
||||
imageDTO = await getImageDTO(cachedImageName);
|
||||
if (imageDTO) {
|
||||
this.log.trace({ rect, imageName: cachedImageName, imageDTO }, 'Using cached composite raster layer image');
|
||||
return imageDTO;
|
||||
}
|
||||
}
|
||||
|
||||
this.log.trace({ rect }, 'Rasterizing composite raster layer');
|
||||
|
||||
const canvas = this.getCompositeRasterLayerCanvas(rect);
|
||||
const blob = await canvasToBlob(canvas);
|
||||
if (this.manager._isDebugging) {
|
||||
previewBlob(blob, 'Composite raster layer canvas');
|
||||
}
|
||||
|
||||
imageDTO = await uploadImage(blob, 'composite-raster-layer.png', 'general', true);
|
||||
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
|
||||
return imageDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the image DTO for the composite inpaint mask.
|
||||
*
|
||||
* If the image is found in the cache, the cached image DTO is returned.
|
||||
*
|
||||
* @param rect The region to include in the image
|
||||
* @returns A promise that resolves to the image DTO
|
||||
*/
|
||||
getCompositeInpaintMaskImageDTO = async (rect: Rect): Promise<ImageDTO> => {
|
||||
let imageDTO: ImageDTO | null = null;
|
||||
|
||||
@@ -284,29 +188,19 @@ export class CanvasCompositorModule extends CanvasModuleBase {
|
||||
}
|
||||
}
|
||||
|
||||
imageDTO = await this.rasterizeAndUploadCompositeInpaintMask(rect, false);
|
||||
this.log.trace({ rect }, 'Rasterizing composite inpaint mask');
|
||||
|
||||
const canvas = this.getCompositeInpaintMaskCanvas(rect);
|
||||
const blob = await canvasToBlob(canvas);
|
||||
if (this.manager._isDebugging) {
|
||||
previewBlob(blob, 'Composite inpaint mask canvas');
|
||||
}
|
||||
|
||||
imageDTO = await uploadImage(blob, 'composite-inpaint-mask.png', 'general', true);
|
||||
this.manager.cache.imageNameCache.set(hash, imageDTO.image_name);
|
||||
return imageDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the generation mode for the current canvas state. This is determined by the transparency of the
|
||||
* composite raster layer and composite inpaint mask:
|
||||
* - Composite raster layer is fully transparent -> txt2img
|
||||
* - Composite raster layer is partially transparent -> outpainting
|
||||
* - Composite raster layer is opaque & composite inpaint mask is fully transparent -> img2img
|
||||
* - Composite raster layer is opaque & composite inpaint mask is partially transparent -> inpainting
|
||||
*
|
||||
* Definitions:
|
||||
* - Fully transparent: all pixels have an alpha value of 0.
|
||||
* - Partially transparent: at least one pixel with an alpha value of 0 & at least one pixel with an alpha value
|
||||
* greater than 0.
|
||||
* - Opaque: all pixels have an alpha value greater than 0.
|
||||
*
|
||||
* The generation mode is cached to avoid recalculating it when the canvas state has not changed.
|
||||
*
|
||||
* @returns The generation mode
|
||||
*/
|
||||
getGenerationMode(): GenerationMode {
|
||||
const { rect } = this.manager.stateApi.getBbox();
|
||||
|
||||
@@ -347,4 +241,21 @@ export class CanvasCompositorModule extends CanvasModuleBase {
|
||||
this.manager.cache.generationModeCache.set(hash, generationMode);
|
||||
return generationMode;
|
||||
}
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
};
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.trace('Destroying compositor module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
|
||||
getLoggingContext = () => {
|
||||
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
|
||||
import { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
|
||||
import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import { getLastPointOfLine } from 'features/controlLayers/konva/util';
|
||||
import type {
|
||||
CanvasBrushLineState,
|
||||
@@ -22,63 +22,33 @@ import type { Logger } from 'roarr';
|
||||
import stableHash from 'stable-hash';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
/**
|
||||
* Handles the rendering for a single raster or control layer entity.
|
||||
*
|
||||
* This module has two main components:
|
||||
* - A transformer, which handles the positioning and interaction state of the layer
|
||||
* - A renderer, which handles the rendering of the layer's objects
|
||||
*
|
||||
* The canvas rendering module interacts with this module to coordinate the rendering of all raster and control layers.
|
||||
*/
|
||||
export class CanvasEntityLayerAdapter extends CanvasModuleBase {
|
||||
export class CanvasEntityLayerAdapter extends CanvasModuleABC {
|
||||
readonly type = 'entity_layer_adapter';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
manager: CanvasManager;
|
||||
parent: CanvasManager;
|
||||
subscriptions = new Set<() => void>();
|
||||
log: Logger;
|
||||
|
||||
/**
|
||||
* The last known state of the entity.
|
||||
*/
|
||||
state: CanvasRasterLayerState | CanvasControlLayerState;
|
||||
|
||||
/**
|
||||
* The Konva nodes that make up the entity layer:
|
||||
* - A layer to hold the everything
|
||||
*
|
||||
* Note that the transformer and object renderer have their own Konva nodes, but they are not stored here.
|
||||
*/
|
||||
konva: {
|
||||
layer: Konva.Layer;
|
||||
};
|
||||
|
||||
/**
|
||||
* The transformer for this entity layer.
|
||||
*/
|
||||
transformer: CanvasEntityTransformer;
|
||||
|
||||
/**
|
||||
* The renderer for this entity layer.
|
||||
*/
|
||||
renderer: CanvasEntityRenderer;
|
||||
|
||||
/**
|
||||
* Whether this is the first render of the entity layer.
|
||||
*/
|
||||
isFirstRender: boolean = true;
|
||||
|
||||
constructor(state: CanvasEntityLayerAdapter['state'], manager: CanvasEntityLayerAdapter['manager']) {
|
||||
super();
|
||||
this.id = state.id;
|
||||
this.manager = manager;
|
||||
this.parent = manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.path = this.manager.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
|
||||
this.log.debug('Creating module');
|
||||
this.log.debug({ state }, 'Creating layer adapter module');
|
||||
|
||||
this.state = state;
|
||||
|
||||
@@ -104,7 +74,15 @@ export class CanvasEntityLayerAdapter extends CanvasModuleBase {
|
||||
return getEntityIdentifier(this.state);
|
||||
};
|
||||
|
||||
update = async (arg?: { state: CanvasEntityLayerAdapter['state'] }): Promise<void> => {
|
||||
destroy = (): void => {
|
||||
this.log.debug('Destroying layer adapter module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.renderer.destroy();
|
||||
this.transformer.destroy();
|
||||
this.konva.layer.destroy();
|
||||
};
|
||||
|
||||
update = async (arg?: { state: CanvasEntityLayerAdapter['state'] }) => {
|
||||
const state = get(arg, 'state', this.state);
|
||||
|
||||
const prevState = this.state;
|
||||
@@ -166,7 +144,23 @@ export class CanvasEntityLayerAdapter extends CanvasModuleBase {
|
||||
}
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
state: deepClone(this.state),
|
||||
transformer: this.transformer.repr(),
|
||||
renderer: this.renderer.repr(),
|
||||
};
|
||||
};
|
||||
|
||||
getLoggingContext = (): SerializableObject => {
|
||||
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
|
||||
getCanvas = (rect?: Rect): HTMLCanvasElement => {
|
||||
// TODO(psyche) - cache this - maybe with package `memoizee`? Would require careful review of cache invalidation
|
||||
this.log.trace({ rect }, 'Getting canvas');
|
||||
// The opacity may have been changed in response to user selecting a different entity category, so we must restore
|
||||
// the original opacity before rendering the canvas
|
||||
@@ -208,21 +202,30 @@ export class CanvasEntityLayerAdapter extends CanvasModuleBase {
|
||||
return null;
|
||||
};
|
||||
|
||||
destroy = (): void => {
|
||||
this.log.debug('Destroying module');
|
||||
this.renderer.destroy();
|
||||
this.transformer.destroy();
|
||||
this.konva.layer.destroy();
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
state: deepClone(this.state),
|
||||
transformer: this.transformer.repr(),
|
||||
renderer: this.renderer.repr(),
|
||||
logDebugInfo(msg = 'Debug info') {
|
||||
const info = {
|
||||
repr: this.repr(),
|
||||
interactionRectAttrs: {
|
||||
x: this.transformer.konva.proxyRect.x(),
|
||||
y: this.transformer.konva.proxyRect.y(),
|
||||
scaleX: this.transformer.konva.proxyRect.scaleX(),
|
||||
scaleY: this.transformer.konva.proxyRect.scaleY(),
|
||||
width: this.transformer.konva.proxyRect.width(),
|
||||
height: this.transformer.konva.proxyRect.height(),
|
||||
rotation: this.transformer.konva.proxyRect.rotation(),
|
||||
},
|
||||
objectGroupAttrs: {
|
||||
x: this.renderer.konva.objectGroup.x(),
|
||||
y: this.renderer.konva.objectGroup.y(),
|
||||
scaleX: this.renderer.konva.objectGroup.scaleX(),
|
||||
scaleY: this.renderer.konva.objectGroup.scaleY(),
|
||||
width: this.renderer.konva.objectGroup.width(),
|
||||
height: this.renderer.konva.objectGroup.height(),
|
||||
rotation: this.renderer.konva.objectGroup.rotation(),
|
||||
offsetX: this.renderer.konva.objectGroup.offsetX(),
|
||||
offsetY: this.renderer.konva.objectGroup.offsetY(),
|
||||
},
|
||||
};
|
||||
};
|
||||
this.log.trace(info, msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
|
||||
import { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
|
||||
import { CanvasEntityTransformer } from 'features/controlLayers/konva/CanvasEntityTransformer';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import { getLastPointOfLine } from 'features/controlLayers/konva/util';
|
||||
import type {
|
||||
CanvasBrushLineState,
|
||||
@@ -21,14 +21,14 @@ import { get, omit } from 'lodash-es';
|
||||
import type { Logger } from 'roarr';
|
||||
import stableHash from 'stable-hash';
|
||||
|
||||
export class CanvasEntityMaskAdapter extends CanvasModuleBase {
|
||||
export class CanvasEntityMaskAdapter extends CanvasModuleABC {
|
||||
readonly type = 'entity_mask_adapter';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
parent: CanvasManager;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
subscriptions = new Set<() => void>();
|
||||
|
||||
state: CanvasInpaintMaskState | CanvasRegionalGuidanceState;
|
||||
|
||||
@@ -44,12 +44,11 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
|
||||
constructor(state: CanvasEntityMaskAdapter['state'], manager: CanvasEntityMaskAdapter['manager']) {
|
||||
super();
|
||||
this.id = state.id;
|
||||
this.parent = manager;
|
||||
this.manager = manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.path = this.manager.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
|
||||
this.log.debug('Creating module');
|
||||
this.log.debug({ state }, 'Creating mask adapter module');
|
||||
|
||||
this.state = state;
|
||||
|
||||
@@ -75,6 +74,14 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
|
||||
return getEntityIdentifier(this.state);
|
||||
};
|
||||
|
||||
destroy = (): void => {
|
||||
this.log.debug('Destroying mask adapter module');
|
||||
|
||||
this.transformer.destroy();
|
||||
this.renderer.destroy();
|
||||
this.konva.layer.destroy();
|
||||
};
|
||||
|
||||
update = async (arg?: { state: CanvasEntityMaskAdapter['state'] }) => {
|
||||
const state = get(arg, 'state', this.state);
|
||||
|
||||
@@ -150,6 +157,15 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
|
||||
return null;
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
state: deepClone(this.state),
|
||||
};
|
||||
};
|
||||
|
||||
getHashableState = (): SerializableObject => {
|
||||
const keysToOmit: (keyof CanvasEntityMaskAdapter['state'])[] = ['fill', 'name', 'opacity'];
|
||||
return omit(this.state, keysToOmit);
|
||||
@@ -164,6 +180,7 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
|
||||
};
|
||||
|
||||
getCanvas = (rect?: Rect): HTMLCanvasElement => {
|
||||
// TODO(psyche): Cache this?
|
||||
// The opacity may have been changed in response to user selecting a different entity category, and the mask regions
|
||||
// should be fully opaque - set opacity to 1 before rendering the canvas
|
||||
const attrs: GroupConfig = { opacity: 1 };
|
||||
@@ -171,19 +188,7 @@ export class CanvasEntityMaskAdapter extends CanvasModuleBase {
|
||||
return canvas;
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying module');
|
||||
this.transformer.destroy();
|
||||
this.renderer.destroy();
|
||||
this.konva.layer.destroy();
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
state: deepClone(this.state),
|
||||
};
|
||||
getLoggingContext = () => {
|
||||
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { rgbColorToString } from 'common/util/colorCodeTransformers';
|
||||
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
|
||||
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import { CanvasObjectBrushLineRenderer } from 'features/controlLayers/konva/CanvasObjectBrushLineRenderer';
|
||||
import { CanvasObjectEraserLineRenderer } from 'features/controlLayers/konva/CanvasObjectEraserLineRenderer';
|
||||
import { CanvasObjectImageRenderer } from 'features/controlLayers/konva/CanvasObjectImageRenderer';
|
||||
@@ -60,7 +60,7 @@ type AnyObjectState = CanvasBrushLineState | CanvasEraserLineState | CanvasImage
|
||||
/**
|
||||
* Handles rendering of objects for a canvas entity.
|
||||
*/
|
||||
export class CanvasEntityRenderer extends CanvasModuleBase {
|
||||
export class CanvasEntityRenderer extends CanvasModuleABC {
|
||||
readonly type = 'entity_renderer';
|
||||
|
||||
id: string;
|
||||
@@ -125,25 +125,16 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The entity's object group as a canvas element along with the pixel rect of the entity at the time the canvas was
|
||||
* drawn.
|
||||
*
|
||||
* Technically, this is an internal Konva object, created when a Konva node's `.cache()` method is called. We cache
|
||||
* the object group after every update, so we get this as a "free" side effect.
|
||||
*
|
||||
* This is used to render the entity's preview in the control layer.
|
||||
*/
|
||||
$canvasCache = atom<{ canvas: HTMLCanvasElement; rect: Rect } | null>(null);
|
||||
|
||||
constructor(parent: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter) {
|
||||
super();
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.parent = parent;
|
||||
this.path = this.parent.path.concat(this.id);
|
||||
this.manager = parent.manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.log.debug('Creating module');
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
this.log.debug('Creating entity object renderer module');
|
||||
|
||||
this.konva = {
|
||||
objectGroup: new Konva.Group({ name: `${this.type}:object_group`, listening: false }),
|
||||
@@ -175,7 +166,7 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
|
||||
// user switches tool mid-drawing, for example by pressing space to pan the stage. It's easy to press space
|
||||
// to pan _before_ releasing the mouse button, which would cause the buffer to be lost if we didn't commit it.
|
||||
this.subscriptions.add(
|
||||
this.manager.tool.$tool.listen(() => {
|
||||
this.manager.stateApi.$tool.listen(() => {
|
||||
this.commitBuffer();
|
||||
})
|
||||
);
|
||||
@@ -183,7 +174,7 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
|
||||
// The compositing rect must cover the whole stage at all times. When the stage is scaled, moved or resized, we
|
||||
// need to update the compositing rect to match the stage.
|
||||
this.subscriptions.add(
|
||||
this.manager.stage.$stageAttrs.listen(() => {
|
||||
this.manager.stateApi.$stageAttrs.listen(() => {
|
||||
if (this.konva.compositing && this.parent.type === 'entity_mask_adapter') {
|
||||
this.updateCompositingRectSize();
|
||||
}
|
||||
@@ -256,7 +247,7 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
|
||||
this.log.trace('Updating compositing rect size');
|
||||
assert(this.konva.compositing, 'Missing compositing rect');
|
||||
|
||||
const { x, y, width, height, scale } = this.manager.stage.$stageAttrs.get();
|
||||
const { x, y, width, height, scale } = this.manager.stateApi.$stageAttrs.get();
|
||||
|
||||
this.konva.compositing.rect.setAttrs({
|
||||
x: -x / scale,
|
||||
@@ -558,18 +549,17 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
|
||||
};
|
||||
|
||||
updatePreviewCanvas = debounce(() => {
|
||||
if (this.parent.transformer.$isPendingRectCalculation.get()) {
|
||||
if (this.parent.transformer.isPendingRectCalculation) {
|
||||
return;
|
||||
}
|
||||
const pixelRect = this.parent.transformer.$pixelRect.get();
|
||||
if (pixelRect.width === 0 || pixelRect.height === 0) {
|
||||
if (this.parent.transformer.pixelRect.width === 0 || this.parent.transformer.pixelRect.height === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// TODO(psyche): This is an internal Konva method, so it may break in the future. Can we make this API public?
|
||||
const canvas = this.konva.objectGroup._getCachedSceneCanvas()._canvas as HTMLCanvasElement | undefined | null;
|
||||
if (canvas) {
|
||||
const nodeRect = this.parent.transformer.$nodeRect.get();
|
||||
const nodeRect = this.parent.transformer.nodeRect;
|
||||
const pixelRect = this.parent.transformer.pixelRect;
|
||||
const rect = {
|
||||
x: pixelRect.x - nodeRect.x,
|
||||
y: pixelRect.y - nodeRect.y,
|
||||
@@ -614,8 +604,11 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
|
||||
return imageData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Destroys this renderer and all of its object renderers.
|
||||
*/
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying module');
|
||||
this.log.debug('Destroying entity object renderer module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
for (const renderer of this.renderers.values()) {
|
||||
renderer.destroy();
|
||||
@@ -623,6 +616,10 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
|
||||
this.renderers.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a serializable representation of the renderer.
|
||||
* @returns A serializable representation of the renderer.
|
||||
*/
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -633,4 +630,8 @@ export class CanvasEntityRenderer extends CanvasModuleBase {
|
||||
buffer: this.bufferRenderer?.repr(),
|
||||
};
|
||||
};
|
||||
|
||||
getLoggingContext = () => {
|
||||
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,116 +1,66 @@
|
||||
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
|
||||
import type { CanvasEntityMaskAdapter } from 'features/controlLayers/konva/CanvasEntityMaskAdapter';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import { canvasToImageData, getEmptyRect, getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import type { Coordinate, Rect, RectWithRotation } from 'features/controlLayers/store/types';
|
||||
import type { Coordinate, Rect } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import type { GroupConfig } from 'konva/lib/Group';
|
||||
import { debounce, get } from 'lodash-es';
|
||||
import { atom } from 'nanostores';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
type CanvasEntityTransformerConfig = {
|
||||
/**
|
||||
* The debounce time in milliseconds for calculating the rect of the parent entity
|
||||
*/
|
||||
RECT_CALC_DEBOUNCE_MS: number;
|
||||
/**
|
||||
* The padding around the scaling transform anchors for hit detection
|
||||
*/
|
||||
ANCHOR_HIT_PADDING: number;
|
||||
/**
|
||||
* The padding around the parent entity when drawing the rect outline
|
||||
*/
|
||||
OUTLINE_PADDING: number;
|
||||
/**
|
||||
* The color of the rect outline
|
||||
*/
|
||||
OUTLINE_COLOR: string;
|
||||
/**
|
||||
* The fill color of the scaling transform anchors
|
||||
*/
|
||||
SCALE_ANCHOR_FILL_COLOR: string;
|
||||
/**
|
||||
* The stroke color of the scaling transform anchors
|
||||
*/
|
||||
SCALE_ANCHOR_STROKE_COLOR: string;
|
||||
/**
|
||||
* The corner radius ratio of the scaling transform anchors
|
||||
*/
|
||||
SCALE_ANCHOR_CORNER_RADIUS_RATIO: number;
|
||||
/**
|
||||
* The stroke width of the scaling transform anchors
|
||||
*/
|
||||
SCALE_ANCHOR_STROKE_WIDTH: number;
|
||||
/**
|
||||
* The size of the scaling transform anchors
|
||||
*/
|
||||
SCALE_ANCHOR_SIZE: number;
|
||||
/**
|
||||
* The fill color of the rotation transform anchor
|
||||
*/
|
||||
ROTATE_ANCHOR_FILL_COLOR: string;
|
||||
/**
|
||||
* The stroke color of the rotation transform anchor
|
||||
*/
|
||||
ROTATE_ANCHOR_STROKE_COLOR: string;
|
||||
/**
|
||||
* The size (height/width) of the rotation transform anchor
|
||||
*/
|
||||
ROTATE_ANCHOR_SIZE: number;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: CanvasEntityTransformerConfig = {
|
||||
RECT_CALC_DEBOUNCE_MS: 300,
|
||||
ANCHOR_HIT_PADDING: 10,
|
||||
OUTLINE_PADDING: 0,
|
||||
OUTLINE_COLOR: 'hsl(200 76% 50% / 1)', // invokeBlue.500
|
||||
SCALE_ANCHOR_FILL_COLOR: 'hsl(200 76% 50% / 1)', // invokeBlue.500
|
||||
SCALE_ANCHOR_STROKE_COLOR: 'hsl(200 76% 77% / 1)', // invokeBlue.200
|
||||
SCALE_ANCHOR_CORNER_RADIUS_RATIO: 0.5,
|
||||
SCALE_ANCHOR_STROKE_WIDTH: 2,
|
||||
SCALE_ANCHOR_SIZE: 8,
|
||||
ROTATE_ANCHOR_FILL_COLOR: 'hsl(200 76% 95% / 1)', // invokeBlue.50
|
||||
ROTATE_ANCHOR_STROKE_COLOR: 'hsl(200 76% 40% / 1)', // invokeBlue.700
|
||||
ROTATE_ANCHOR_SIZE: 12,
|
||||
};
|
||||
|
||||
export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
/**
|
||||
* The CanvasTransformer class is responsible for managing the transformation of a canvas entity:
|
||||
* - Moving
|
||||
* - Resizing
|
||||
* - Rotating
|
||||
*
|
||||
* It renders an outline when dragging and resizing the entity, with transform anchors for resizing and rotation.
|
||||
*/
|
||||
export class CanvasEntityTransformer extends CanvasModuleABC {
|
||||
readonly type = 'entity_transformer';
|
||||
|
||||
static RECT_CALC_DEBOUNCE_MS = 300;
|
||||
static OUTLINE_PADDING = 0;
|
||||
static OUTLINE_COLOR = 'hsl(200 76% 50% / 1)'; // invokeBlue.500
|
||||
|
||||
static ANCHOR_FILL_COLOR = CanvasEntityTransformer.OUTLINE_COLOR;
|
||||
static ANCHOR_STROKE_COLOR = 'hsl(200 76% 77% / 1)'; // invokeBlue.200
|
||||
static ANCHOR_CORNER_RADIUS_RATIO = 0.5;
|
||||
static ANCHOR_STROKE_WIDTH = 2;
|
||||
static ANCHOR_HIT_PADDING = 10;
|
||||
|
||||
static RESIZE_ANCHOR_SIZE = 8;
|
||||
|
||||
static ROTATE_ANCHOR_FILL_COLOR = 'hsl(200 76% 95% / 1)'; // invokeBlue.50
|
||||
static ROTATE_ANCHOR_STROKE_COLOR = 'hsl(200 76% 40% / 1)'; // invokeBlue.700
|
||||
static ROTATE_ANCHOR_SIZE = 12;
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
parent: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
|
||||
config: CanvasEntityTransformerConfig = DEFAULT_CONFIG;
|
||||
|
||||
/**
|
||||
* The rect of the parent, _including_ transparent regions.
|
||||
* It is calculated via Konva's getClientRect method, which is fast but includes transparent regions.
|
||||
*
|
||||
* Stored as a nanostores atom for easy reactivity.
|
||||
*/
|
||||
$nodeRect = atom<Rect>(getEmptyRect());
|
||||
nodeRect = getEmptyRect();
|
||||
|
||||
/**
|
||||
* The rect of the parent, _excluding_ transparent regions.
|
||||
* If the parent's nodes have no possibility of transparent regions, this will be calculated the same way as nodeRect.
|
||||
* If the parent's nodes may have transparent regions, this will be calculated manually by rasterizing the parent and
|
||||
* checking the pixel data.
|
||||
*
|
||||
* Stored as a nanostores atom for easy reactivity.
|
||||
*/
|
||||
$pixelRect = atom<Rect>(getEmptyRect());
|
||||
pixelRect = getEmptyRect();
|
||||
|
||||
/**
|
||||
* Whether the transformer is currently calculating the rect of the parent.
|
||||
*
|
||||
* Stored as a nanostores atom for easy reactivity.
|
||||
*/
|
||||
$isPendingRectCalculation = atom<boolean>(true);
|
||||
isPendingRectCalculation: boolean = true;
|
||||
|
||||
/**
|
||||
* A set of subscriptions that should be cleaned up when the transformer is destroyed.
|
||||
@@ -119,40 +69,27 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
|
||||
/**
|
||||
* Whether the transformer is currently transforming the entity.
|
||||
*
|
||||
* Stored as a nanostores atom for easy reactivity.
|
||||
*/
|
||||
$isTransforming = atom<boolean>(false);
|
||||
isTransforming: boolean = false;
|
||||
|
||||
/**
|
||||
* The current interaction mode of the transformer:
|
||||
* - 'all': The entity can be moved, resized, and rotated.
|
||||
* - 'drag': The entity can be moved.
|
||||
* - 'off': The transformer is not interactable.
|
||||
*
|
||||
* Stored as a nanostores atom for easy reactivity.
|
||||
*/
|
||||
$interactionMode = atom<'all' | 'drag' | 'off'>('off');
|
||||
interactionMode: 'all' | 'drag' | 'off' = 'off';
|
||||
|
||||
/**
|
||||
* Whether dragging is enabled. Dragging is enabled in both 'all' and 'drag' interaction modes.
|
||||
*
|
||||
* Stored as a nanostores atom for easy reactivity.
|
||||
*/
|
||||
$isDragEnabled = atom<boolean>(false);
|
||||
isDragEnabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Whether transforming is enabled. Transforming is enabled only in 'all' interaction mode.
|
||||
*
|
||||
* Stored as a nanostores atom for easy reactivity.
|
||||
*/
|
||||
$isTransformEnabled = atom<boolean>(false);
|
||||
isTransformEnabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Whether the transformer is currently processing (rasterizing and uploading) the transformed entity.
|
||||
*
|
||||
* Stored as a nanostores atom for easy reactivity.
|
||||
*/
|
||||
$isProcessing = atom(false);
|
||||
|
||||
konva: {
|
||||
@@ -161,22 +98,21 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
outlineRect: Konva.Rect;
|
||||
};
|
||||
|
||||
constructor(parent: CanvasEntityTransformer['parent']) {
|
||||
constructor(parent: CanvasEntityLayerAdapter | CanvasEntityMaskAdapter) {
|
||||
super();
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.parent = parent;
|
||||
this.manager = parent.manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
|
||||
this.log.debug('Creating module');
|
||||
this.path = this.parent.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
this.log.debug('Creating entity transformer module');
|
||||
|
||||
this.konva = {
|
||||
outlineRect: new Konva.Rect({
|
||||
listening: false,
|
||||
draggable: false,
|
||||
name: `${this.type}:outline_rect`,
|
||||
stroke: this.config.OUTLINE_COLOR,
|
||||
stroke: CanvasEntityTransformer.OUTLINE_COLOR,
|
||||
perfectDrawEnabled: false,
|
||||
strokeHitEnabled: false,
|
||||
}),
|
||||
@@ -192,18 +128,111 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
// Transforming will retain aspect ratio only when shift is held
|
||||
keepRatio: false,
|
||||
// The padding is the distance between the transformer bbox and the nodes
|
||||
padding: this.config.OUTLINE_PADDING,
|
||||
padding: CanvasEntityTransformer.OUTLINE_PADDING,
|
||||
// This is `invokeBlue.400`
|
||||
stroke: this.config.OUTLINE_COLOR,
|
||||
anchorFill: this.config.SCALE_ANCHOR_FILL_COLOR,
|
||||
anchorStroke: this.config.SCALE_ANCHOR_STROKE_COLOR,
|
||||
anchorStrokeWidth: this.config.SCALE_ANCHOR_STROKE_WIDTH,
|
||||
anchorSize: this.config.SCALE_ANCHOR_SIZE,
|
||||
anchorCornerRadius: this.config.SCALE_ANCHOR_SIZE * this.config.SCALE_ANCHOR_CORNER_RADIUS_RATIO,
|
||||
stroke: CanvasEntityTransformer.OUTLINE_COLOR,
|
||||
anchorFill: CanvasEntityTransformer.ANCHOR_FILL_COLOR,
|
||||
anchorStroke: CanvasEntityTransformer.ANCHOR_STROKE_COLOR,
|
||||
anchorStrokeWidth: CanvasEntityTransformer.ANCHOR_STROKE_WIDTH,
|
||||
anchorSize: CanvasEntityTransformer.RESIZE_ANCHOR_SIZE,
|
||||
anchorCornerRadius:
|
||||
CanvasEntityTransformer.RESIZE_ANCHOR_SIZE * CanvasEntityTransformer.ANCHOR_CORNER_RADIUS_RATIO,
|
||||
// This function is called for each anchor to style it (and do anything else you might want to do).
|
||||
anchorStyleFunc: this.anchorStyleFunc,
|
||||
anchorDragBoundFunc: this.anchorDragBoundFunc,
|
||||
boundBoxFunc: this.boxBoundFunc,
|
||||
anchorStyleFunc: (anchor) => {
|
||||
// Give the rotater special styling
|
||||
if (anchor.hasName('rotater')) {
|
||||
anchor.setAttrs({
|
||||
height: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE,
|
||||
width: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE,
|
||||
cornerRadius:
|
||||
CanvasEntityTransformer.ROTATE_ANCHOR_SIZE * CanvasEntityTransformer.ANCHOR_CORNER_RADIUS_RATIO,
|
||||
fill: CanvasEntityTransformer.ROTATE_ANCHOR_FILL_COLOR,
|
||||
stroke: CanvasEntityTransformer.ANCHOR_FILL_COLOR,
|
||||
offsetX: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE / 2,
|
||||
offsetY: CanvasEntityTransformer.ROTATE_ANCHOR_SIZE / 2,
|
||||
});
|
||||
}
|
||||
// Add some padding to the hit area of the anchors
|
||||
anchor.hitFunc((context) => {
|
||||
context.beginPath();
|
||||
context.rect(
|
||||
-CanvasEntityTransformer.ANCHOR_HIT_PADDING,
|
||||
-CanvasEntityTransformer.ANCHOR_HIT_PADDING,
|
||||
anchor.width() + CanvasEntityTransformer.ANCHOR_HIT_PADDING * 2,
|
||||
anchor.height() + CanvasEntityTransformer.ANCHOR_HIT_PADDING * 2
|
||||
);
|
||||
context.closePath();
|
||||
context.fillStrokeShape(anchor);
|
||||
});
|
||||
},
|
||||
anchorDragBoundFunc: (oldPos: Coordinate, newPos: Coordinate) => {
|
||||
// The anchorDragBoundFunc callback puts constraints on the movement of the transformer anchors, which in
|
||||
// turn constrain the transformation. It is called on every anchor move. We'll use this to snap the anchors
|
||||
// to the nearest pixel.
|
||||
|
||||
// If we are rotating, no need to do anything - just let the rotation happen.
|
||||
if (this.konva.transformer.getActiveAnchor() === 'rotater') {
|
||||
return newPos;
|
||||
}
|
||||
|
||||
// We need to snap the anchor to the nearest pixel, but the positions provided to this callback are absolute,
|
||||
// scaled coordinates. They need to be converted to stage coordinates, snapped, then converted back to absolute
|
||||
// before returning them.
|
||||
const stageScale = this.manager.stage.getScale();
|
||||
const stagePos = this.manager.stage.getPosition();
|
||||
|
||||
// Unscale and round the target position to the nearest pixel.
|
||||
const targetX = Math.round(newPos.x / stageScale);
|
||||
const targetY = Math.round(newPos.y / stageScale);
|
||||
|
||||
// The stage may be offset a fraction of a pixel. To ensure the anchor snaps to the nearest pixel, we need to
|
||||
// calculate that offset and add it back to the target position.
|
||||
|
||||
// Calculate the offset. It's the remainder of the stage position divided by the scale * desired grid size. In
|
||||
// this case, the grid size is 1px. For example, if we wanted to snap to the nearest 8px, the calculation would
|
||||
// be `stagePos.x % (stageScale * 8)`.
|
||||
const scaledOffsetX = stagePos.x % stageScale;
|
||||
const scaledOffsetY = stagePos.y % stageScale;
|
||||
|
||||
// Unscale the target position and add the offset to get the absolute position for this anchor.
|
||||
const scaledTargetX = targetX * stageScale + scaledOffsetX;
|
||||
const scaledTargetY = targetY * stageScale + scaledOffsetY;
|
||||
|
||||
this.log.trace(
|
||||
{
|
||||
oldPos,
|
||||
newPos,
|
||||
stageScale,
|
||||
stagePos,
|
||||
targetX,
|
||||
targetY,
|
||||
scaledOffsetX,
|
||||
scaledOffsetY,
|
||||
scaledTargetX,
|
||||
scaledTargetY,
|
||||
},
|
||||
'Anchor drag bound'
|
||||
);
|
||||
|
||||
return { x: scaledTargetX, y: scaledTargetY };
|
||||
},
|
||||
boundBoxFunc: (oldBoundBox, newBoundBox) => {
|
||||
// Bail if we are not rotating, we don't need to do anything.
|
||||
if (this.konva.transformer.getActiveAnchor() !== 'rotater') {
|
||||
return newBoundBox;
|
||||
}
|
||||
|
||||
// This transform constraint operates on the bounding box of the transformer. This box has x, y, width, and
|
||||
// height in stage coordinates, and rotation in radians. This can be used to snap the transformer rotation to
|
||||
// the nearest 45 degrees when shift is held.
|
||||
if (this.manager.stateApi.$shiftKey.get()) {
|
||||
if (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) {
|
||||
return oldBoundBox;
|
||||
}
|
||||
}
|
||||
|
||||
return newBoundBox;
|
||||
},
|
||||
}),
|
||||
proxyRect: new Konva.Rect({
|
||||
name: `${this.type}:proxy_rect`,
|
||||
@@ -212,15 +241,129 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
}),
|
||||
};
|
||||
|
||||
this.konva.transformer.on('transform', this.syncObjectGroupWithProxyRect);
|
||||
this.konva.transformer.on('transformend', this.snapProxyRectToPixelGrid);
|
||||
this.konva.proxyRect.on('dragmove', this.onDragMove);
|
||||
this.konva.proxyRect.on('dragend', this.onDragEnd);
|
||||
this.konva.transformer.on('transformstart', () => {
|
||||
// Just logging in this callback. Called on mouse down of a transform anchor.
|
||||
this.log.trace(
|
||||
{
|
||||
x: this.konva.proxyRect.x(),
|
||||
y: this.konva.proxyRect.y(),
|
||||
scaleX: this.konva.proxyRect.scaleX(),
|
||||
scaleY: this.konva.proxyRect.scaleY(),
|
||||
rotation: this.konva.proxyRect.rotation(),
|
||||
},
|
||||
'Transform started'
|
||||
);
|
||||
});
|
||||
|
||||
this.konva.transformer.on('transform', () => {
|
||||
// This is called when a transform anchor is dragged. By this time, the transform constraints in the above
|
||||
// callbacks have been enforced, and the transformer has updated its nodes' attributes. We need to pass the
|
||||
// updated attributes to the object group, propagating the transformation on down.
|
||||
this.syncObjectGroupWithProxyRect();
|
||||
});
|
||||
|
||||
this.konva.transformer.on('transformend', () => {
|
||||
// Called on mouse up on an anchor. We'll do some final snapping to ensure the transformer is pixel-perfect.
|
||||
|
||||
// Snap the position to the nearest pixel.
|
||||
const x = this.konva.proxyRect.x();
|
||||
const y = this.konva.proxyRect.y();
|
||||
const snappedX = Math.round(x);
|
||||
const snappedY = Math.round(y);
|
||||
|
||||
// The transformer doesn't modify the width and height. It only modifies scale. We'll need to apply the scale to
|
||||
// the width and height, round them to the nearest pixel, and finally calculate a new scale that will result in
|
||||
// the snapped width and height.
|
||||
const width = this.konva.proxyRect.width();
|
||||
const height = this.konva.proxyRect.height();
|
||||
const scaleX = this.konva.proxyRect.scaleX();
|
||||
const scaleY = this.konva.proxyRect.scaleY();
|
||||
|
||||
// Determine the target width and height, rounded to the nearest pixel. Must be >= 1. Because the scales can be
|
||||
// negative, we need to take the absolute value of the width and height.
|
||||
const targetWidth = Math.max(Math.abs(Math.round(width * scaleX)), 1);
|
||||
const targetHeight = Math.max(Math.abs(Math.round(height * scaleY)), 1);
|
||||
|
||||
// Calculate the scale we need to use to get the target width and height. Restore the sign of the scales.
|
||||
const snappedScaleX = (targetWidth / width) * Math.sign(scaleX);
|
||||
const snappedScaleY = (targetHeight / height) * Math.sign(scaleY);
|
||||
|
||||
// Update interaction rect and object group attributes.
|
||||
this.konva.proxyRect.setAttrs({
|
||||
x: snappedX,
|
||||
y: snappedY,
|
||||
scaleX: snappedScaleX,
|
||||
scaleY: snappedScaleY,
|
||||
});
|
||||
this.parent.renderer.konva.objectGroup.setAttrs({
|
||||
x: snappedX,
|
||||
y: snappedY,
|
||||
scaleX: snappedScaleX,
|
||||
scaleY: snappedScaleY,
|
||||
});
|
||||
|
||||
// Rotation is only retrieved for logging purposes.
|
||||
const rotation = this.konva.proxyRect.rotation();
|
||||
|
||||
this.log.trace(
|
||||
{
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
scaleX,
|
||||
scaleY,
|
||||
rotation,
|
||||
snappedX,
|
||||
snappedY,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
snappedScaleX,
|
||||
snappedScaleY,
|
||||
},
|
||||
'Transform ended'
|
||||
);
|
||||
});
|
||||
|
||||
this.konva.proxyRect.on('dragmove', () => {
|
||||
// Snap the interaction rect to the nearest pixel
|
||||
this.konva.proxyRect.x(Math.round(this.konva.proxyRect.x()));
|
||||
this.konva.proxyRect.y(Math.round(this.konva.proxyRect.y()));
|
||||
|
||||
// The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding
|
||||
// and border
|
||||
this.konva.outlineRect.setAttrs({
|
||||
x: this.konva.proxyRect.x() - this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING),
|
||||
y: this.konva.proxyRect.y() - this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING),
|
||||
});
|
||||
|
||||
// The object group is translated by the difference between the interaction rect's new and old positions (which is
|
||||
// stored as this.pixelRect)
|
||||
this.parent.renderer.konva.objectGroup.setAttrs({
|
||||
x: this.konva.proxyRect.x(),
|
||||
y: this.konva.proxyRect.y(),
|
||||
});
|
||||
});
|
||||
this.konva.proxyRect.on('dragend', () => {
|
||||
if (this.isTransforming) {
|
||||
// If we are transforming the entity, we should not push the new position to the state. This will trigger a
|
||||
// re-render of the entity and bork the transformation.
|
||||
return;
|
||||
}
|
||||
|
||||
const position = {
|
||||
x: this.konva.proxyRect.x() - this.pixelRect.x,
|
||||
y: this.konva.proxyRect.y() - this.pixelRect.y,
|
||||
};
|
||||
|
||||
this.log.trace({ position }, 'Position changed');
|
||||
this.manager.stateApi.setEntityPosition({ entityIdentifier: this.parent.getEntityIdentifier(), position });
|
||||
});
|
||||
|
||||
// When the stage scale changes, we may need to re-scale some of the transformer's components. For example,
|
||||
// the bbox outline should always be 1 screen pixel wide, so we need to update its stroke width.
|
||||
this.subscriptions.add(
|
||||
this.manager.stage.$stageAttrs.listen((newVal, oldVal) => {
|
||||
// When the stage scale changes, we may need to re-scale some of the transformer's components. For example,
|
||||
// the bbox outline should always be 1 screen pixel wide, so we need to update its stroke width.
|
||||
this.manager.stateApi.$stageAttrs.listen((newVal, oldVal) => {
|
||||
if (newVal.scale !== oldVal.scale) {
|
||||
this.syncScale();
|
||||
}
|
||||
@@ -236,7 +379,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
);
|
||||
|
||||
// When the selected tool changes, we need to update the transformer's interaction state.
|
||||
this.subscriptions.add(this.manager.tool.$tool.listen(this.syncInteractionState));
|
||||
this.subscriptions.add(this.manager.stateApi.$tool.listen(this.syncInteractionState));
|
||||
|
||||
// When the selected entity changes, we need to update the transformer's interaction state.
|
||||
this.subscriptions.add(this.manager.stateApi.$selectedEntityIdentifier.listen(this.syncInteractionState));
|
||||
@@ -246,182 +389,6 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
this.parent.konva.layer.add(this.konva.transformer);
|
||||
}
|
||||
|
||||
anchorStyleFunc = (anchor: Konva.Rect): void => {
|
||||
// Give the rotater special styling
|
||||
if (anchor.hasName('rotater')) {
|
||||
anchor.setAttrs({
|
||||
height: this.config.ROTATE_ANCHOR_SIZE,
|
||||
width: this.config.ROTATE_ANCHOR_SIZE,
|
||||
cornerRadius: this.config.ROTATE_ANCHOR_SIZE * this.config.SCALE_ANCHOR_CORNER_RADIUS_RATIO,
|
||||
fill: this.config.ROTATE_ANCHOR_FILL_COLOR,
|
||||
stroke: this.config.SCALE_ANCHOR_FILL_COLOR,
|
||||
offsetX: this.config.ROTATE_ANCHOR_SIZE / 2,
|
||||
offsetY: this.config.ROTATE_ANCHOR_SIZE / 2,
|
||||
});
|
||||
}
|
||||
// Add some padding to the hit area of the anchors
|
||||
anchor.hitFunc((context) => {
|
||||
context.beginPath();
|
||||
context.rect(
|
||||
-this.config.ANCHOR_HIT_PADDING,
|
||||
-this.config.ANCHOR_HIT_PADDING,
|
||||
anchor.width() + this.config.ANCHOR_HIT_PADDING * 2,
|
||||
anchor.height() + this.config.ANCHOR_HIT_PADDING * 2
|
||||
);
|
||||
context.closePath();
|
||||
context.fillStrokeShape(anchor);
|
||||
});
|
||||
};
|
||||
|
||||
anchorDragBoundFunc = (oldPos: Coordinate, newPos: Coordinate) => {
|
||||
// The anchorDragBoundFunc callback puts constraints on the movement of the transformer anchors, which in
|
||||
// turn constrain the transformation. It is called on every anchor move. We'll use this to snap the anchors
|
||||
// to the nearest pixel.
|
||||
|
||||
// If we are rotating, no need to do anything - just let the rotation happen.
|
||||
if (this.konva.transformer.getActiveAnchor() === 'rotater') {
|
||||
return newPos;
|
||||
}
|
||||
|
||||
// We need to snap the anchor to the nearest pixel, but the positions provided to this callback are absolute,
|
||||
// scaled coordinates. They need to be converted to stage coordinates, snapped, then converted back to absolute
|
||||
// before returning them.
|
||||
const stageScale = this.manager.stage.getScale();
|
||||
const stagePos = this.manager.stage.getPosition();
|
||||
|
||||
// Unscale and round the target position to the nearest pixel.
|
||||
const targetX = Math.round(newPos.x / stageScale);
|
||||
const targetY = Math.round(newPos.y / stageScale);
|
||||
|
||||
// The stage may be offset a fraction of a pixel. To ensure the anchor snaps to the nearest pixel, we need to
|
||||
// calculate that offset and add it back to the target position.
|
||||
|
||||
// Calculate the offset. It's the remainder of the stage position divided by the scale * desired grid size. In
|
||||
// this case, the grid size is 1px. For example, if we wanted to snap to the nearest 8px, the calculation would
|
||||
// be `stagePos.x % (stageScale * 8)`.
|
||||
const scaledOffsetX = stagePos.x % stageScale;
|
||||
const scaledOffsetY = stagePos.y % stageScale;
|
||||
|
||||
// Unscale the target position and add the offset to get the absolute position for this anchor.
|
||||
const scaledTargetX = targetX * stageScale + scaledOffsetX;
|
||||
const scaledTargetY = targetY * stageScale + scaledOffsetY;
|
||||
|
||||
return { x: scaledTargetX, y: scaledTargetY };
|
||||
};
|
||||
|
||||
boxBoundFunc = (oldBoundBox: RectWithRotation, newBoundBox: RectWithRotation) => {
|
||||
// Bail if we are not rotating, we don't need to do anything.
|
||||
if (this.konva.transformer.getActiveAnchor() !== 'rotater') {
|
||||
return newBoundBox;
|
||||
}
|
||||
|
||||
// This transform constraint operates on the bounding box of the transformer. This box has x, y, width, and
|
||||
// height in stage coordinates, and rotation in radians. This can be used to snap the transformer rotation to
|
||||
// the nearest 45 degrees when shift is held.
|
||||
if (this.manager.stateApi.$shiftKey.get()) {
|
||||
if (Math.abs(newBoundBox.rotation % (Math.PI / 4)) > 0) {
|
||||
return oldBoundBox;
|
||||
}
|
||||
}
|
||||
|
||||
return newBoundBox;
|
||||
};
|
||||
|
||||
/**
|
||||
* Snaps the proxy rect to the nearest pixel, syncing the object group with the proxy rect.
|
||||
*/
|
||||
snapProxyRectToPixelGrid = () => {
|
||||
// Called on mouse up on an anchor. We'll do some final snapping to ensure the transformer is pixel-perfect.
|
||||
|
||||
// Snap the position to the nearest pixel.
|
||||
const x = this.konva.proxyRect.x();
|
||||
const y = this.konva.proxyRect.y();
|
||||
const snappedX = Math.round(x);
|
||||
const snappedY = Math.round(y);
|
||||
|
||||
// The transformer doesn't modify the width and height. It only modifies scale. We'll need to apply the scale to
|
||||
// the width and height, round them to the nearest pixel, and finally calculate a new scale that will result in
|
||||
// the snapped width and height.
|
||||
const width = this.konva.proxyRect.width();
|
||||
const height = this.konva.proxyRect.height();
|
||||
const scaleX = this.konva.proxyRect.scaleX();
|
||||
const scaleY = this.konva.proxyRect.scaleY();
|
||||
|
||||
// Determine the target width and height, rounded to the nearest pixel. Must be >= 1. Because the scales can be
|
||||
// negative, we need to take the absolute value of the width and height.
|
||||
const targetWidth = Math.max(Math.abs(Math.round(width * scaleX)), 1);
|
||||
const targetHeight = Math.max(Math.abs(Math.round(height * scaleY)), 1);
|
||||
|
||||
// Calculate the scale we need to use to get the target width and height. Restore the sign of the scales.
|
||||
const snappedScaleX = (targetWidth / width) * Math.sign(scaleX);
|
||||
const snappedScaleY = (targetHeight / height) * Math.sign(scaleY);
|
||||
|
||||
// Update interaction rect and object group attributes.
|
||||
this.konva.proxyRect.setAttrs({
|
||||
x: snappedX,
|
||||
y: snappedY,
|
||||
scaleX: snappedScaleX,
|
||||
scaleY: snappedScaleY,
|
||||
});
|
||||
|
||||
this.syncObjectGroupWithProxyRect();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fits the proxy rect to the bounding box of the parent entity, then syncs the object group with the proxy rect.
|
||||
*/
|
||||
fitProxyRectToBbox = () => {
|
||||
const { rect } = this.manager.stateApi.getBbox();
|
||||
const scaleX = rect.width / this.konva.proxyRect.width();
|
||||
const scaleY = rect.height / this.konva.proxyRect.height();
|
||||
this.konva.proxyRect.setAttrs({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
scaleX,
|
||||
scaleY,
|
||||
rotation: 0,
|
||||
});
|
||||
this.syncObjectGroupWithProxyRect();
|
||||
};
|
||||
|
||||
onDragMove = () => {
|
||||
// Snap the interaction rect to the nearest pixel
|
||||
this.konva.proxyRect.x(Math.round(this.konva.proxyRect.x()));
|
||||
this.konva.proxyRect.y(Math.round(this.konva.proxyRect.y()));
|
||||
|
||||
// The bbox should be updated to reflect the new position of the interaction rect, taking into account its padding
|
||||
// and border
|
||||
this.konva.outlineRect.setAttrs({
|
||||
x: this.konva.proxyRect.x() - this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING),
|
||||
y: this.konva.proxyRect.y() - this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING),
|
||||
});
|
||||
|
||||
// The object group is translated by the difference between the interaction rect's new and old positions (which is
|
||||
// stored as this.pixelRect)
|
||||
this.parent.renderer.konva.objectGroup.setAttrs({
|
||||
x: this.konva.proxyRect.x(),
|
||||
y: this.konva.proxyRect.y(),
|
||||
});
|
||||
};
|
||||
|
||||
onDragEnd = () => {
|
||||
if (this.$isTransforming.get()) {
|
||||
// If we are transforming the entity, we should not push the new position to the state. This will trigger a
|
||||
// re-render of the entity and bork the transformation.
|
||||
return;
|
||||
}
|
||||
|
||||
const pixelRect = this.$pixelRect.get();
|
||||
|
||||
const position = {
|
||||
x: this.konva.proxyRect.x() - pixelRect.x,
|
||||
y: this.konva.proxyRect.y() - pixelRect.y,
|
||||
};
|
||||
|
||||
this.log.trace({ position }, 'Position changed');
|
||||
this.manager.stateApi.setEntityPosition({ entityIdentifier: this.parent.getEntityIdentifier(), position });
|
||||
};
|
||||
|
||||
// TODO(psyche): These don't work when the entity is rotated, need to do some math to offset the flip after rotation
|
||||
// flipHorizontal = () => {
|
||||
// if (!this.isTransforming || this.$isProcessing.get()) {
|
||||
@@ -477,7 +444,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
*/
|
||||
update = (position: Coordinate, bbox: Rect) => {
|
||||
const onePixel = this.manager.stage.getScaledPixels(1);
|
||||
const bboxPadding = this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING);
|
||||
const bboxPadding = this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING);
|
||||
|
||||
this.konva.outlineRect.setAttrs({
|
||||
x: position.x + bbox.x - bboxPadding,
|
||||
@@ -501,17 +468,14 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
syncInteractionState = () => {
|
||||
this.log.trace('Syncing interaction state');
|
||||
|
||||
const pixelRect = this.$pixelRect.get();
|
||||
const isPendingRectCalculation = this.$isPendingRectCalculation.get();
|
||||
|
||||
if (isPendingRectCalculation || pixelRect.width === 0 || pixelRect.height === 0) {
|
||||
if (this.isPendingRectCalculation || this.pixelRect.width === 0 || this.pixelRect.height === 0) {
|
||||
// If the rect is being calculated, or if the rect has no width or height, we can't interact with the transformer
|
||||
this.parent.konva.layer.listening(false);
|
||||
this.setInteractionMode('off');
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = this.manager.tool.$tool.get();
|
||||
const tool = this.manager.stateApi.$tool.get();
|
||||
const isSelected = this.manager.stateApi.getIsSelected(this.parent.id);
|
||||
|
||||
if (!this.parent.renderer.hasObjects() || this.parent.state.isLocked || !this.parent.state.isEnabled) {
|
||||
@@ -521,11 +485,11 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelected && !this.$isTransforming.get() && tool === 'move') {
|
||||
if (isSelected && !this.isTransforming && tool === 'move') {
|
||||
// We are moving this layer, it must be listening
|
||||
this.parent.konva.layer.listening(true);
|
||||
this.setInteractionMode('drag');
|
||||
} else if (isSelected && this.$isTransforming.get()) {
|
||||
} else if (isSelected && this.isTransforming) {
|
||||
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer is
|
||||
// active, it will interrupt the stage drag events. So we should disable listening when the view tool is selected.
|
||||
if (tool !== 'view') {
|
||||
@@ -547,7 +511,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
*/
|
||||
syncScale = () => {
|
||||
const onePixel = this.manager.stage.getScaledPixels(1);
|
||||
const bboxPadding = this.manager.stage.getScaledPixels(this.config.OUTLINE_PADDING);
|
||||
const bboxPadding = this.manager.stage.getScaledPixels(CanvasEntityTransformer.OUTLINE_PADDING);
|
||||
|
||||
this.konva.outlineRect.setAttrs({
|
||||
x: this.konva.proxyRect.x() - bboxPadding,
|
||||
@@ -564,16 +528,16 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
*/
|
||||
startTransform = () => {
|
||||
this.log.debug('Starting transform');
|
||||
this.$isTransforming.set(true);
|
||||
this.manager.tool.$tool.set('move');
|
||||
this.isTransforming = true;
|
||||
this.manager.stateApi.$tool.set('move');
|
||||
// When transforming, we want the stage to still be movable if the view tool is selected. If the transformer or
|
||||
// interaction rect are listening, it will interrupt the stage's drag events. So we should disable listening
|
||||
// when the view tool is selected
|
||||
// TODO(psyche): We just set the tool to 'move', why would it be 'view'? Investigate and figure out if this is needed
|
||||
const shouldListen = this.manager.tool.$tool.get() !== 'view';
|
||||
const shouldListen = this.manager.stateApi.$tool.get() !== 'view';
|
||||
this.parent.konva.layer.listening(shouldListen);
|
||||
this.setInteractionMode('all');
|
||||
this.manager.stateApi.$transformingAdapter.set(this.parent);
|
||||
this.manager.stateApi.$transformingEntity.set(this.parent.getEntityIdentifier());
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -583,7 +547,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
this.log.debug('Applying transform');
|
||||
this.$isProcessing.set(true);
|
||||
const rect = this.getRelativeRect();
|
||||
await this.parent.renderer.rasterize({ rect, replaceObjects: true, attrs: { filters: [] } });
|
||||
await this.parent.renderer.rasterize({ rect, replaceObjects: true });
|
||||
this.requestRectCalculation();
|
||||
this.stopTransform();
|
||||
};
|
||||
@@ -601,14 +565,14 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
stopTransform = () => {
|
||||
this.log.debug('Stopping transform');
|
||||
|
||||
this.$isTransforming.set(false);
|
||||
this.isTransforming = false;
|
||||
this.setInteractionMode('off');
|
||||
|
||||
// Reset the transform of the the entity. We've either replaced the transformed objects with a rasterized image, or
|
||||
// canceled a transformation. In either case, the scale should be reset.
|
||||
this.resetTransform();
|
||||
this.syncInteractionState();
|
||||
this.manager.stateApi.$transformingAdapter.set(null);
|
||||
this.manager.stateApi.$transformingEntity.set(null);
|
||||
this.$isProcessing.set(false);
|
||||
};
|
||||
|
||||
@@ -637,17 +601,16 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
this.log.trace('Updating position');
|
||||
const position = get(arg, 'position', this.parent.state.position);
|
||||
|
||||
const pixelRect = this.$pixelRect.get();
|
||||
const groupAttrs: Partial<GroupConfig> = {
|
||||
x: position.x + pixelRect.x,
|
||||
y: position.y + pixelRect.y,
|
||||
offsetX: pixelRect.x,
|
||||
offsetY: pixelRect.y,
|
||||
x: position.x + this.pixelRect.x,
|
||||
y: position.y + this.pixelRect.y,
|
||||
offsetX: this.pixelRect.x,
|
||||
offsetY: this.pixelRect.y,
|
||||
};
|
||||
this.parent.renderer.konva.objectGroup.setAttrs(groupAttrs);
|
||||
this.parent.renderer.konva.bufferGroup.setAttrs(groupAttrs);
|
||||
|
||||
this.update(position, pixelRect);
|
||||
this.update(position, this.pixelRect);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -658,7 +621,7 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
* - 'off': The transformer is not interactable.
|
||||
*/
|
||||
setInteractionMode = (interactionMode: 'all' | 'drag' | 'off') => {
|
||||
this.$interactionMode.set(interactionMode);
|
||||
this.interactionMode = interactionMode;
|
||||
if (interactionMode === 'drag') {
|
||||
this._enableDrag();
|
||||
this._disableTransform();
|
||||
@@ -675,19 +638,16 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
};
|
||||
|
||||
updateBbox = () => {
|
||||
const nodeRect = this.$nodeRect.get();
|
||||
const pixelRect = this.$pixelRect.get();
|
||||
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect }, 'Updating bbox');
|
||||
|
||||
this.log.trace({ nodeRect, pixelRect }, 'Updating bbox');
|
||||
|
||||
if (this.$isPendingRectCalculation.get()) {
|
||||
if (this.isPendingRectCalculation) {
|
||||
this.syncInteractionState();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the bbox has no width or height, that means the layer is fully transparent. This can happen if it is only
|
||||
// eraser lines, fully clipped brush lines or if it has been fully erased.
|
||||
if (pixelRect.width === 0 || pixelRect.height === 0) {
|
||||
if (this.pixelRect.width === 0 || this.pixelRect.height === 0) {
|
||||
// If the layer already has no objects, we don't need to reset the entity state. This would cause a push to the
|
||||
// undo stack and clear the redo stack.
|
||||
if (this.parent.renderer.hasObjects()) {
|
||||
@@ -696,12 +656,12 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
}
|
||||
} else {
|
||||
this.syncInteractionState();
|
||||
this.update(this.parent.state.position, pixelRect);
|
||||
this.update(this.parent.state.position, this.pixelRect);
|
||||
const groupAttrs: Partial<GroupConfig> = {
|
||||
x: this.parent.state.position.x + pixelRect.x,
|
||||
y: this.parent.state.position.y + pixelRect.y,
|
||||
offsetX: pixelRect.x,
|
||||
offsetY: pixelRect.y,
|
||||
x: this.parent.state.position.x + this.pixelRect.x,
|
||||
y: this.parent.state.position.y + this.pixelRect.y,
|
||||
offsetX: this.pixelRect.x,
|
||||
offsetY: this.pixelRect.y,
|
||||
};
|
||||
this.parent.renderer.konva.objectGroup.setAttrs(groupAttrs);
|
||||
this.parent.renderer.konva.bufferGroup.setAttrs(groupAttrs);
|
||||
@@ -713,13 +673,13 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
calculateRect = debounce(() => {
|
||||
this.log.debug('Calculating bbox');
|
||||
|
||||
this.$isPendingRectCalculation.set(true);
|
||||
this.isPendingRectCalculation = true;
|
||||
|
||||
if (!this.parent.renderer.hasObjects()) {
|
||||
this.log.trace('No objects, resetting bbox');
|
||||
this.$nodeRect.set(getEmptyRect());
|
||||
this.$pixelRect.set(getEmptyRect());
|
||||
this.$isPendingRectCalculation.set(false);
|
||||
this.nodeRect = getEmptyRect();
|
||||
this.pixelRect = getEmptyRect();
|
||||
this.isPendingRectCalculation = false;
|
||||
this.updateBbox();
|
||||
return;
|
||||
}
|
||||
@@ -727,10 +687,10 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
const rect = this.parent.renderer.konva.objectGroup.getClientRect({ skipTransform: true });
|
||||
|
||||
if (!this.parent.renderer.needsPixelBbox()) {
|
||||
this.$nodeRect.set({ ...rect });
|
||||
this.$pixelRect.set({ ...rect });
|
||||
this.log.trace({ nodeRect: this.$nodeRect.get(), pixelRect: this.$pixelRect.get() }, 'Got bbox from client rect');
|
||||
this.$isPendingRectCalculation.set(false);
|
||||
this.nodeRect = { ...rect };
|
||||
this.pixelRect = { ...rect };
|
||||
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect }, 'Got bbox from client rect');
|
||||
this.isPendingRectCalculation = false;
|
||||
this.updateBbox();
|
||||
return;
|
||||
}
|
||||
@@ -743,29 +703,26 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
(extents) => {
|
||||
if (extents) {
|
||||
const { minX, minY, maxX, maxY } = extents;
|
||||
this.$nodeRect.set({ ...rect });
|
||||
this.$pixelRect.set({
|
||||
this.nodeRect = { ...rect };
|
||||
this.pixelRect = {
|
||||
x: Math.round(rect.x) + minX,
|
||||
y: Math.round(rect.y) + minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
});
|
||||
};
|
||||
} else {
|
||||
this.$nodeRect.set(getEmptyRect());
|
||||
this.$pixelRect.set(getEmptyRect());
|
||||
this.nodeRect = getEmptyRect();
|
||||
this.pixelRect = getEmptyRect();
|
||||
}
|
||||
this.log.trace(
|
||||
{ nodeRect: this.$nodeRect.get(), pixelRect: this.$pixelRect.get(), extents },
|
||||
`Got bbox from worker`
|
||||
);
|
||||
this.$isPendingRectCalculation.set(false);
|
||||
this.log.trace({ nodeRect: this.nodeRect, pixelRect: this.pixelRect, extents }, `Got bbox from worker`);
|
||||
this.isPendingRectCalculation = false;
|
||||
this.updateBbox();
|
||||
}
|
||||
);
|
||||
}, this.config.RECT_CALC_DEBOUNCE_MS);
|
||||
}, CanvasEntityTransformer.RECT_CALC_DEBOUNCE_MS);
|
||||
|
||||
requestRectCalculation = () => {
|
||||
this.$isPendingRectCalculation.set(true);
|
||||
this.isPendingRectCalculation = true;
|
||||
this.syncInteractionState();
|
||||
this.calculateRect();
|
||||
};
|
||||
@@ -775,27 +732,27 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
};
|
||||
|
||||
_enableTransform = () => {
|
||||
this.$isTransformEnabled.set(true);
|
||||
this.isTransformEnabled = true;
|
||||
this.konva.transformer.visible(true);
|
||||
this.konva.transformer.listening(true);
|
||||
this.konva.transformer.nodes([this.konva.proxyRect]);
|
||||
};
|
||||
|
||||
_disableTransform = () => {
|
||||
this.$isTransformEnabled.set(false);
|
||||
this.isTransformEnabled = false;
|
||||
this.konva.transformer.visible(false);
|
||||
this.konva.transformer.listening(false);
|
||||
this.konva.transformer.nodes([]);
|
||||
};
|
||||
|
||||
_enableDrag = () => {
|
||||
this.$isDragEnabled.set(true);
|
||||
this.isDragEnabled = true;
|
||||
this.konva.proxyRect.visible(true);
|
||||
this.konva.proxyRect.listening(true);
|
||||
};
|
||||
|
||||
_disableDrag = () => {
|
||||
this.$isDragEnabled.set(false);
|
||||
this.isDragEnabled = false;
|
||||
this.konva.proxyRect.visible(false);
|
||||
this.konva.proxyRect.listening(false);
|
||||
};
|
||||
@@ -808,27 +765,32 @@ export class CanvasEntityTransformer extends CanvasModuleBase {
|
||||
this.konva.outlineRect.visible(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a JSON-serializable object that describes the transformer.
|
||||
*/
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
nodeRect: this.$nodeRect.get(),
|
||||
pixelRect: this.$pixelRect.get(),
|
||||
isPendingRectCalculation: this.$isPendingRectCalculation.get(),
|
||||
isTransforming: this.$isTransforming.get(),
|
||||
interactionMode: this.$interactionMode.get(),
|
||||
isDragEnabled: this.$isDragEnabled.get(),
|
||||
isTransformEnabled: this.$isTransformEnabled.get(),
|
||||
isProcessing: this.$isProcessing.get(),
|
||||
mode: this.interactionMode,
|
||||
isTransformEnabled: this.isTransformEnabled,
|
||||
isDragEnabled: this.isDragEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Destroys the transformer, cleaning up any subscriptions.
|
||||
*/
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying module');
|
||||
this.log.debug('Destroying entity transformer module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.konva.outlineRect.destroy();
|
||||
this.konva.transformer.destroy();
|
||||
this.konva.proxyRect.destroy();
|
||||
};
|
||||
|
||||
getLoggingContext = () => {
|
||||
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
|
||||
import { alignCoordForTool, getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import Konva from 'konva';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
type EraserToolPreviewConfig = {
|
||||
/**
|
||||
* The inner border color for the eraser tool preview.
|
||||
*/
|
||||
BORDER_INNER_COLOR: string;
|
||||
/**
|
||||
* The outer border color for the eraser tool preview.
|
||||
*/
|
||||
BORDER_OUTER_COLOR: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: EraserToolPreviewConfig = {
|
||||
BORDER_INNER_COLOR: 'rgba(0,0,0,1)',
|
||||
BORDER_OUTER_COLOR: 'rgba(255,255,255,0.8)',
|
||||
};
|
||||
|
||||
export class CanvasEraserToolPreview extends CanvasModuleBase {
|
||||
readonly type = 'eraser_tool_preview';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
parent: CanvasToolModule;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
|
||||
config: EraserToolPreviewConfig = DEFAULT_CONFIG;
|
||||
|
||||
konva: {
|
||||
group: Konva.Group;
|
||||
cutoutCircle: Konva.Circle;
|
||||
innerBorder: Konva.Ring;
|
||||
outerBorder: Konva.Ring;
|
||||
};
|
||||
|
||||
constructor(parent: CanvasToolModule) {
|
||||
super();
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.parent = parent;
|
||||
this.manager = this.parent.manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
|
||||
this.log.debug('Creating module');
|
||||
|
||||
this.konva = {
|
||||
group: new Konva.Group({ name: `${this.type}:eraser_group`, listening: false }),
|
||||
cutoutCircle: new Konva.Circle({
|
||||
name: `${this.type}:eraser_cutout_circle`,
|
||||
listening: false,
|
||||
strokeEnabled: false,
|
||||
// The fill is used only to erase what is underneath it, so its color doesn't matter - just needs to be opaque
|
||||
fill: 'white',
|
||||
globalCompositeOperation: 'destination-out',
|
||||
}),
|
||||
innerBorder: new Konva.Ring({
|
||||
name: `${this.type}:eraser_inner_border_ring`,
|
||||
listening: false,
|
||||
innerRadius: 0,
|
||||
outerRadius: 0,
|
||||
fill: this.config.BORDER_INNER_COLOR,
|
||||
strokeEnabled: false,
|
||||
}),
|
||||
outerBorder: new Konva.Ring({
|
||||
name: `${this.type}:eraser_outer_border_ring`,
|
||||
innerRadius: 0,
|
||||
outerRadius: 0,
|
||||
fill: this.config.BORDER_OUTER_COLOR,
|
||||
strokeEnabled: false,
|
||||
}),
|
||||
};
|
||||
this.konva.group.add(this.konva.cutoutCircle, this.konva.innerBorder, this.konva.outerBorder);
|
||||
}
|
||||
|
||||
render = () => {
|
||||
const cursorPos = this.manager.tool.$lastCursorPos.get();
|
||||
|
||||
if (!cursorPos) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolState = this.manager.stateApi.getToolState();
|
||||
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
|
||||
const radius = toolState.eraser.width / 2;
|
||||
|
||||
// The circle is scaled
|
||||
this.konva.cutoutCircle.setAttrs({
|
||||
x: alignedCursorPos.x,
|
||||
y: alignedCursorPos.y,
|
||||
radius,
|
||||
});
|
||||
|
||||
// But the borders are in screen-pixels
|
||||
const onePixel = this.manager.stage.getScaledPixels(1);
|
||||
const twoPixels = this.manager.stage.getScaledPixels(2);
|
||||
|
||||
this.konva.innerBorder.setAttrs({
|
||||
x: cursorPos.x,
|
||||
y: cursorPos.y,
|
||||
innerRadius: radius,
|
||||
outerRadius: radius + onePixel,
|
||||
});
|
||||
this.konva.outerBorder.setAttrs({
|
||||
x: cursorPos.x,
|
||||
y: cursorPos.y,
|
||||
innerRadius: radius + onePixel,
|
||||
outerRadius: radius + twoPixels,
|
||||
});
|
||||
};
|
||||
|
||||
setVisibility = (visible: boolean) => {
|
||||
this.konva.group.visible(visible);
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
config: this.config,
|
||||
};
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying eraser tool preview module');
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SerializableObject } from 'common/types';
|
||||
import type { CanvasEntityLayerAdapter } from 'features/controlLayers/konva/CanvasEntityLayerAdapter';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import type { CanvasEntityIdentifier, CanvasImageState, FilterConfig } from 'features/controlLayers/store/types';
|
||||
import { IMAGE_FILTERS, imageDTOToImageObject } from 'features/controlLayers/store/types';
|
||||
@@ -11,14 +11,14 @@ import { getImageDTO } from 'services/api/endpoints/images';
|
||||
import type { BatchConfig, ImageDTO, S } from 'services/api/types';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
export class CanvasFilterModule extends CanvasModuleBase {
|
||||
export class CanvasFilterModule extends CanvasModuleABC {
|
||||
readonly type = 'canvas_filter';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
parent: CanvasManager;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
subscriptions = new Set<() => void>();
|
||||
|
||||
imageState: CanvasImageState | null = null;
|
||||
|
||||
@@ -30,10 +30,9 @@ export class CanvasFilterModule extends CanvasModuleBase {
|
||||
constructor(manager: CanvasManager) {
|
||||
super();
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.parent = manager;
|
||||
this.manager = manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.path = this.manager.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
|
||||
this.log.debug('Creating filter module');
|
||||
}
|
||||
@@ -50,7 +49,7 @@ export class CanvasFilterModule extends CanvasModuleBase {
|
||||
return;
|
||||
}
|
||||
this.$adapter.set(entity.adapter);
|
||||
this.manager.tool.$tool.set('view');
|
||||
this.manager.stateApi.$tool.set('view');
|
||||
};
|
||||
|
||||
previewFilter = async () => {
|
||||
@@ -168,4 +167,21 @@ export class CanvasFilterModule extends CanvasModuleBase {
|
||||
|
||||
return batch;
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.trace('Destroying filter module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
};
|
||||
};
|
||||
|
||||
getLoggingContext = () => {
|
||||
return { ...this.manager.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,36 +3,32 @@ import { logger } from 'app/logging/logger';
|
||||
import type { AppStore } from 'app/store/store';
|
||||
import type { SerializableObject } from 'common/types';
|
||||
import { SyncableMap } from 'common/util/SyncableMap/SyncableMap';
|
||||
import { CanvasBboxModule } from 'features/controlLayers/konva/CanvasBboxModule';
|
||||
import { CanvasCacheModule } from 'features/controlLayers/konva/CanvasCacheModule';
|
||||
import { CanvasCompositorModule } from 'features/controlLayers/konva/CanvasCompositorModule';
|
||||
import { CanvasFilterModule } from 'features/controlLayers/konva/CanvasFilterModule';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasProgressImageModule } from 'features/controlLayers/konva/CanvasProgressImageModule';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import { CanvasRenderingModule } from 'features/controlLayers/konva/CanvasRenderingModule';
|
||||
import { CanvasStageModule } from 'features/controlLayers/konva/CanvasStageModule';
|
||||
import { CanvasStagingAreaModule } from 'features/controlLayers/konva/CanvasStagingAreaModule';
|
||||
import { CanvasToolModule } from 'features/controlLayers/konva/CanvasToolModule';
|
||||
import { CanvasWorkerModule } from 'features/controlLayers/konva/CanvasWorkerModule.js';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import Konva from 'konva';
|
||||
import type Konva from 'konva';
|
||||
import { atom } from 'nanostores';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
import { CanvasBackgroundModule } from './CanvasBackgroundModule';
|
||||
import type { CanvasEntityLayerAdapter } from './CanvasEntityLayerAdapter';
|
||||
import type { CanvasEntityMaskAdapter } from './CanvasEntityMaskAdapter';
|
||||
import { CanvasPreviewModule } from './CanvasPreviewModule';
|
||||
import { CanvasStateApiModule } from './CanvasStateApiModule';
|
||||
|
||||
export const $canvasManager = atom<CanvasManager | null>(null);
|
||||
|
||||
export class CanvasManager extends CanvasModuleBase {
|
||||
export class CanvasManager extends CanvasModuleABC {
|
||||
readonly type = 'manager';
|
||||
|
||||
id: string;
|
||||
path: string[];
|
||||
manager: CanvasManager;
|
||||
parent: CanvasManager;
|
||||
log: Logger;
|
||||
|
||||
store: AppStore;
|
||||
@@ -56,6 +52,7 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
};
|
||||
|
||||
stateApi: CanvasStateApiModule;
|
||||
preview: CanvasPreviewModule;
|
||||
background: CanvasBackgroundModule;
|
||||
filter: CanvasFilterModule;
|
||||
stage: CanvasStageModule;
|
||||
@@ -63,14 +60,6 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
cache: CanvasCacheModule;
|
||||
renderer: CanvasRenderingModule;
|
||||
compositor: CanvasCompositorModule;
|
||||
tool: CanvasToolModule;
|
||||
bbox: CanvasBboxModule;
|
||||
stagingArea: CanvasStagingAreaModule;
|
||||
progressImage: CanvasProgressImageModule;
|
||||
|
||||
konva: {
|
||||
previewLayer: Konva.Layer;
|
||||
};
|
||||
|
||||
_isDebugging: boolean = false;
|
||||
|
||||
@@ -79,7 +68,6 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
this.id = getPrefixedId(this.type);
|
||||
this.path = [this.id];
|
||||
this.manager = this;
|
||||
this.parent = this;
|
||||
this.log = logger('canvas').child((message) => {
|
||||
return {
|
||||
...message,
|
||||
@@ -99,28 +87,14 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
this.worker = new CanvasWorkerModule(this);
|
||||
this.cache = new CanvasCacheModule(this);
|
||||
this.renderer = new CanvasRenderingModule(this);
|
||||
this.preview = new CanvasPreviewModule(this);
|
||||
this.filter = new CanvasFilterModule(this);
|
||||
|
||||
this.compositor = new CanvasCompositorModule(this);
|
||||
this.stage.addLayer(this.preview.getLayer());
|
||||
|
||||
this.background = new CanvasBackgroundModule(this);
|
||||
this.stage.addLayer(this.background.konva.layer);
|
||||
|
||||
this.konva = {
|
||||
previewLayer: new Konva.Layer({ listening: false, imageSmoothingEnabled: false }),
|
||||
};
|
||||
this.stage.addLayer(this.konva.previewLayer);
|
||||
|
||||
this.tool = new CanvasToolModule(this);
|
||||
this.stagingArea = new CanvasStagingAreaModule(this);
|
||||
this.progressImage = new CanvasProgressImageModule(this);
|
||||
this.bbox = new CanvasBboxModule(this);
|
||||
|
||||
// Must add in this order for correct z-index
|
||||
this.konva.previewLayer.add(this.stagingArea.konva.group);
|
||||
this.konva.previewLayer.add(this.progressImage.konva.group);
|
||||
this.konva.previewLayer.add(this.bbox.konva.group);
|
||||
this.konva.previewLayer.add(this.tool.konva.group);
|
||||
}
|
||||
|
||||
enableDebugging() {
|
||||
@@ -136,7 +110,7 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
this.log.debug('Initializing canvas manager module');
|
||||
|
||||
// These atoms require the canvas manager to be set up before we can provide their initial values
|
||||
this.stateApi.$transformingAdapter.set(null);
|
||||
this.stateApi.$transformingEntity.set(null);
|
||||
this.stateApi.$toolState.set(this.stateApi.getToolState());
|
||||
this.stateApi.$selectedEntityIdentifier.set(this.stateApi.getCanvasState().selectedEntityIdentifier);
|
||||
this.stateApi.$currentFill.set(this.stateApi.getCurrentFill());
|
||||
@@ -147,28 +121,19 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
};
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying module');
|
||||
|
||||
this.log.debug('Destroying canvas manager module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
for (const adapter of this.adapters.getAll()) {
|
||||
adapter.destroy();
|
||||
}
|
||||
|
||||
this.bbox.destroy();
|
||||
this.stagingArea.destroy();
|
||||
this.tool.destroy();
|
||||
this.progressImage.destroy();
|
||||
this.konva.previewLayer.destroy();
|
||||
|
||||
this.stateApi.destroy();
|
||||
this.preview.destroy();
|
||||
this.background.destroy();
|
||||
this.filter.destroy();
|
||||
this.worker.destroy();
|
||||
this.renderer.destroy();
|
||||
this.compositor.destroy();
|
||||
this.stage.destroy();
|
||||
|
||||
$canvasManager.set(null);
|
||||
};
|
||||
|
||||
@@ -187,10 +152,7 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
inpaintMasks: Array.from(this.adapters.inpaintMasks.values()).map((adapter) => adapter.repr()),
|
||||
regionMasks: Array.from(this.adapters.regionMasks.values()).map((adapter) => adapter.repr()),
|
||||
stateApi: this.stateApi.repr(),
|
||||
bbox: this.bbox.repr(),
|
||||
stagingArea: this.stagingArea.repr(),
|
||||
tool: this.tool.repr(),
|
||||
progressImage: this.progressImage.repr(),
|
||||
preview: this.preview.repr(),
|
||||
background: this.background.repr(),
|
||||
filter: this.filter.repr(),
|
||||
worker: this.worker.repr(),
|
||||
@@ -200,19 +162,19 @@ export class CanvasManager extends CanvasModuleBase {
|
||||
};
|
||||
};
|
||||
|
||||
getLoggingContext = (): SerializableObject => ({ path: this.path });
|
||||
|
||||
buildPath = (canvasModule: CanvasModuleBase): string[] => {
|
||||
return canvasModule.parent.path.concat(canvasModule.id);
|
||||
getLoggingContext = (): SerializableObject => {
|
||||
return {
|
||||
path: this.path.join('.'),
|
||||
};
|
||||
};
|
||||
|
||||
buildLogger = (canvasModule: CanvasModuleBase): Logger => {
|
||||
buildLogger = (getContext: () => SerializableObject): Logger => {
|
||||
return this.log.child((message) => {
|
||||
return {
|
||||
...message,
|
||||
context: {
|
||||
...message.context,
|
||||
...canvasModule.getLoggingContext(),
|
||||
...getContext(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SerializableObject } from 'common/types';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
export abstract class CanvasModuleABC {
|
||||
abstract id: string;
|
||||
abstract type: string;
|
||||
abstract path: string[];
|
||||
abstract manager: CanvasManager;
|
||||
abstract log: Logger;
|
||||
abstract subscriptions: Set<() => void>;
|
||||
|
||||
abstract getLoggingContext: () => SerializableObject;
|
||||
abstract destroy: () => void;
|
||||
abstract repr: () => SerializableObject & {
|
||||
id: string;
|
||||
path: string[];
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { SerializableObject } from 'common/types';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
export abstract class CanvasModuleBase {
|
||||
/**
|
||||
* The unique identifier of the module.
|
||||
*
|
||||
* If the module is associated with an entity, this should be the entity's id. Otherwise, the id should be based on
|
||||
* the module's type. The `getPrefixedId` utility should be used for generating ids.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* this.id = getPrefixedId(this.type);
|
||||
* // this.id -> "raster_layer:aS2NREsrlz"
|
||||
* ```
|
||||
*/
|
||||
abstract id: string;
|
||||
/**
|
||||
* The type of the module.
|
||||
*/
|
||||
abstract type: string;
|
||||
/**
|
||||
* The path of the module in the canvas module tree.
|
||||
*
|
||||
* Modules should use the manager's `buildPath` method to set this value.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* this.path = this.manager.buildPath(this);
|
||||
* // this.path -> ["manager:3PWJWmHbou", "raster_layer:aS2NREsrlz", "entity_renderer:sfLO4j1B0n", "brush_line:Zrsu8gpZMd"]
|
||||
* ```
|
||||
*/
|
||||
abstract path: string[];
|
||||
/**
|
||||
* The canvas manager.
|
||||
*/
|
||||
abstract manager: CanvasManager;
|
||||
/**
|
||||
* The parent module. This may be the canvas manager or another module.
|
||||
*/
|
||||
abstract parent: CanvasModuleBase;
|
||||
/**
|
||||
* The logger for the module. The logger must be a `ROARR` logger.
|
||||
*
|
||||
* Modules should use the manager's `buildLogger` method to set this value.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* this.log = this.manager.buildLogger(this);
|
||||
* ```
|
||||
*/
|
||||
abstract log: Logger;
|
||||
|
||||
/**
|
||||
* Returns a logging context object that includes relevant information about the module.
|
||||
* Canvas modules may override this method to include additional information in the logging context, but should
|
||||
* always include the parent's logging context.
|
||||
*
|
||||
* The default implementation includes the parent context and the module's path.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* getLoggingContext = () => {
|
||||
* return {
|
||||
* ...this.parent.getLoggingContext(),
|
||||
* path: this.path,
|
||||
* someImportantValue: this.someImportantValue,
|
||||
* };
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
getLoggingContext: () => SerializableObject = () => {
|
||||
return {
|
||||
...this.parent.getLoggingContext(),
|
||||
path: this.path,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleans up the module when it is disposed.
|
||||
*
|
||||
* Canvas modules may override this method to clean up any loose ends. For example:
|
||||
* - Destroy Konva nodes
|
||||
* - Unsubscribe from any subscriptions
|
||||
* - Abort async operations
|
||||
* - Close websockets
|
||||
* - Terminate workers
|
||||
*
|
||||
* This method is called when the module is disposed. For example:
|
||||
* - When an entity is deleted and its module is destroyed
|
||||
* - When the canvas manager is destroyed
|
||||
*
|
||||
* The default implementation only logs a message.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* destroy = () => {
|
||||
* this.log('Destroying module');
|
||||
* this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
* this.konva.group.destroy();
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
destroy: () => void = () => {
|
||||
this.log('Destroying module');
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a serializable representation of the module.
|
||||
* Canvas modules may override this method to include additional information in the representation.
|
||||
* The default implementation includes id, type, and path.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* repr = () => {
|
||||
* return {
|
||||
* id: this.id,
|
||||
* type: this.type,
|
||||
* path: this.path,
|
||||
* state: deepClone(this.state),
|
||||
* };
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
repr: () => SerializableObject = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
path: this.path,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { rgbaColorToString } from 'common/util/colorCodeTransformers';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import type { CanvasBrushLineState } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
|
||||
export class CanvasObjectBrushLineRenderer extends CanvasModuleABC {
|
||||
readonly type = 'object_brush_line_renderer';
|
||||
|
||||
id: string;
|
||||
@@ -15,6 +15,7 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
|
||||
parent: CanvasEntityRenderer;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
subscriptions = new Set<() => void>();
|
||||
|
||||
state: CanvasBrushLineState;
|
||||
konva: {
|
||||
@@ -28,10 +29,10 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
|
||||
this.id = id;
|
||||
this.parent = parent;
|
||||
this.manager = parent.manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.path = this.parent.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
|
||||
this.log.debug({ state }, 'Creating module');
|
||||
this.log.debug({ state }, 'Creating brush line renderer module');
|
||||
|
||||
this.konva = {
|
||||
group: new Konva.Group({
|
||||
@@ -70,16 +71,17 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
|
||||
return false;
|
||||
}
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying brush line renderer module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
|
||||
setVisibility(isVisible: boolean): void {
|
||||
this.log.trace({ isVisible }, 'Setting brush line visibility');
|
||||
this.konva.group.visible(isVisible);
|
||||
}
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying module');
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -89,4 +91,8 @@ export class CanvasObjectBrushLineRenderer extends CanvasModuleBase {
|
||||
state: deepClone(this.state),
|
||||
};
|
||||
};
|
||||
|
||||
getLoggingContext = () => {
|
||||
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import type { CanvasEraserLineState } from 'features/controlLayers/store/types';
|
||||
import Konva from 'konva';
|
||||
import type { Logger } from 'roarr';
|
||||
|
||||
export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
|
||||
export class CanvasObjectEraserLineRenderer extends CanvasModuleABC {
|
||||
readonly type = 'object_eraser_line_renderer';
|
||||
|
||||
id: string;
|
||||
@@ -14,6 +14,7 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
|
||||
parent: CanvasEntityRenderer;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
subscriptions = new Set<() => void>();
|
||||
|
||||
state: CanvasEraserLineState;
|
||||
konva: {
|
||||
@@ -23,18 +24,19 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
|
||||
|
||||
constructor(state: CanvasEraserLineState, parent: CanvasEntityRenderer) {
|
||||
super();
|
||||
this.id = state.id;
|
||||
const { id, clip } = state;
|
||||
this.id = id;
|
||||
this.parent = parent;
|
||||
this.manager = parent.manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.path = this.parent.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
|
||||
this.log.debug({ state }, 'Creating eraser line renderer module');
|
||||
|
||||
this.konva = {
|
||||
group: new Konva.Group({
|
||||
name: `${this.type}:group`,
|
||||
clip: state.clip,
|
||||
clip,
|
||||
listening: false,
|
||||
}),
|
||||
line: new Konva.Line({
|
||||
@@ -68,16 +70,17 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
|
||||
return false;
|
||||
}
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying eraser line renderer module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
|
||||
setVisibility(isVisible: boolean): void {
|
||||
this.log.trace({ isVisible }, 'Setting brush line visibility');
|
||||
this.konva.group.visible(isVisible);
|
||||
}
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying module');
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
|
||||
repr = () => {
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -87,4 +90,8 @@ export class CanvasObjectEraserLineRenderer extends CanvasModuleBase {
|
||||
state: deepClone(this.state),
|
||||
};
|
||||
};
|
||||
|
||||
getLoggingContext = () => {
|
||||
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { deepClone } from 'common/util/deepClone';
|
||||
import type { CanvasEntityRenderer } from 'features/controlLayers/konva/CanvasEntityRenderer';
|
||||
import type { CanvasFilterModule } from 'features/controlLayers/konva/CanvasFilterModule';
|
||||
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
|
||||
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
|
||||
import { CanvasModuleABC } from 'features/controlLayers/konva/CanvasModuleABC';
|
||||
import type { CanvasStagingAreaModule } from 'features/controlLayers/konva/CanvasStagingAreaModule';
|
||||
import { loadImage } from 'features/controlLayers/konva/util';
|
||||
import type { CanvasImageState } from 'features/controlLayers/store/types';
|
||||
@@ -12,7 +12,7 @@ import Konva from 'konva';
|
||||
import type { Logger } from 'roarr';
|
||||
import { getImageDTO } from 'services/api/endpoints/images';
|
||||
|
||||
export class CanvasObjectImageRenderer extends CanvasModuleBase {
|
||||
export class CanvasObjectImageRenderer extends CanvasModuleABC {
|
||||
readonly type = 'object_image_renderer';
|
||||
|
||||
id: string;
|
||||
@@ -20,6 +20,7 @@ export class CanvasObjectImageRenderer extends CanvasModuleBase {
|
||||
parent: CanvasEntityRenderer | CanvasStagingAreaModule | CanvasFilterModule;
|
||||
manager: CanvasManager;
|
||||
log: Logger;
|
||||
subscriptions = new Set<() => void>();
|
||||
|
||||
state: CanvasImageState;
|
||||
konva: {
|
||||
@@ -34,15 +35,15 @@ export class CanvasObjectImageRenderer extends CanvasModuleBase {
|
||||
|
||||
constructor(state: CanvasImageState, parent: CanvasEntityRenderer | CanvasStagingAreaModule | CanvasFilterModule) {
|
||||
super();
|
||||
this.id = state.id;
|
||||
const { id, image } = state;
|
||||
const { width, height } = image;
|
||||
this.id = id;
|
||||
this.parent = parent;
|
||||
this.manager = parent.manager;
|
||||
this.path = this.manager.buildPath(this);
|
||||
this.log = this.manager.buildLogger(this);
|
||||
this.path = this.parent.path.concat(this.id);
|
||||
this.log = this.manager.buildLogger(this.getLoggingContext);
|
||||
|
||||
this.log.debug({ state }, 'Creating module');
|
||||
|
||||
const { width, height } = state.image;
|
||||
this.log.debug({ state }, 'Creating image renderer module');
|
||||
|
||||
this.konva = {
|
||||
group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
|
||||
@@ -168,6 +169,7 @@ export class CanvasObjectImageRenderer extends CanvasModuleBase {
|
||||
|
||||
destroy = () => {
|
||||
this.log.debug('Destroying image renderer module');
|
||||
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
this.konva.group.destroy();
|
||||
};
|
||||
|
||||
@@ -187,4 +189,8 @@ export class CanvasObjectImageRenderer extends CanvasModuleBase {
|
||||
state: deepClone(this.state),
|
||||
};
|
||||
};
|
||||
|
||||
getLoggingContext = () => {
|
||||
return { ...this.parent.getLoggingContext(), path: this.path.join('.') };
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user