@gradio/imageeditor 0.4.10 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,16 @@
1
1
  <script lang="ts">
2
- import { createEventDispatcher, getContext, onMount, tick } from "svelte";
3
- import { DropdownArrow } from "@gradio/icons";
2
+ import { getContext, onMount, tick } from "svelte";
3
+
4
4
  import { click_outside } from "../utils/events";
5
5
  import { layer_manager, type LayerScene } from "./utils";
6
6
  import { EDITOR_KEY, type EditorContext } from "../ImageEditor.svelte";
7
7
  import type { FileData } from "@gradio/client";
8
+ import { Layers } from "@gradio/icons";
8
9
 
9
10
  let show_layers = false;
10
11
 
11
12
  export let layer_files: (FileData | null)[] | null = [];
13
+ export let enable_layers = true;
12
14
 
13
15
  const { pixi, current_layer, dimensions, register_context } =
14
16
  getContext<EditorContext>(EDITOR_KEY);
@@ -25,6 +27,20 @@
25
27
  }
26
28
  });
27
29
 
30
+ async function validate_layers(): Promise<void> {
31
+ let invalid = layers.some(
32
+ (layer) =>
33
+ layer.composite.texture?.width != $dimensions[0] ||
34
+ layer.composite.texture?.height != $dimensions[1]
35
+ );
36
+ if (invalid) {
37
+ LayerManager.reset();
38
+ if (!layer_files || layer_files.length == 0) new_layer();
39
+ else render_layer_files(layer_files);
40
+ }
41
+ }
42
+ $: $dimensions, validate_layers();
43
+
28
44
  async function new_layer(): Promise<void> {
29
45
  if (!$pixi) return;
30
46
 
@@ -48,7 +64,11 @@
48
64
  _layer_files: typeof layer_files
49
65
  ): Promise<void> {
50
66
  await tick();
51
- if (!_layer_files || _layer_files.length == 0) return;
67
+ if (!_layer_files || _layer_files.length == 0) {
68
+ LayerManager.reset();
69
+ new_layer();
70
+ return;
71
+ }
52
72
  if (!$pixi) return;
53
73
 
54
74
  const fetch_promises = await Promise.all(
@@ -73,7 +93,8 @@
73
93
  last_layer = await LayerManager.add_layer_from_blob(
74
94
  $pixi.layer_container,
75
95
  $pixi.renderer,
76
- blob
96
+ blob,
97
+ $pixi.view
77
98
  );
78
99
  }
79
100
 
@@ -91,75 +112,79 @@
91
112
  });
92
113
  </script>
93
114
 
94
- <div
95
- class="layer-wrap"
96
- class:closed={!show_layers}
97
- use:click_outside={() => (show_layers = false)}
98
- >
99
- <button aria-label="Show Layers" on:click={() => (show_layers = !show_layers)}
100
- >Layers<span class="layer-toggle"><DropdownArrow /></span></button
115
+ {#if enable_layers}
116
+ <div
117
+ class="layer-wrap"
118
+ class:closed={!show_layers}
119
+ use:click_outside={() => (show_layers = false)}
101
120
  >
102
- {#if show_layers}
103
- <ul>
104
- {#each layers as layer, i (i)}
121
+ <button
122
+ aria-label="Show Layers"
123
+ on:click={() => (show_layers = !show_layers)}
124
+ ><span class="icon"><Layers /></span> Layer {layers.findIndex(
125
+ (l) => l === $current_layer
126
+ ) + 1}
127
+ </button>
128
+ {#if show_layers}
129
+ <ul>
130
+ {#each layers as layer, i (i)}
131
+ <li>
132
+ <button
133
+ class:selected_layer={$current_layer === layer}
134
+ on:click={() =>
135
+ ($current_layer = LayerManager.change_active_layer(i))}
136
+ >Layer {i + 1}</button
137
+ >
138
+ </li>
139
+ {/each}
105
140
  <li>
106
- <button
107
- class:selected_layer={$current_layer === layer}
108
- on:click={() =>
109
- ($current_layer = LayerManager.change_active_layer(i))}
110
- >Layer {i + 1}</button
111
- >
141
+ <button aria-label="Add Layer" on:click={new_layer}> +</button>
112
142
  </li>
113
- {/each}
114
- <li>
115
- <button aria-label="Add Layer" on:click={new_layer}> +</button>
116
- </li>
117
- </ul>
118
- {/if}
119
- </div>
143
+ </ul>
144
+ {/if}
120
145
 
121
- <style>
122
- .layer-toggle {
123
- width: 20px;
124
- transform: rotate(0deg);
125
- }
146
+ <span class="sep"></span>
147
+ </div>
148
+ {/if}
126
149
 
127
- .closed .layer-toggle {
128
- transform: rotate(-90deg);
150
+ <style>
151
+ .icon {
152
+ width: 14px;
153
+ margin-right: var(--spacing-md);
154
+ color: var(--block-label-text-color);
155
+ margin-right: var(--spacing-lg);
156
+ margin-top: 1px;
129
157
  }
130
158
 
131
159
  .layer-wrap {
132
- position: absolute;
133
- bottom: 0;
134
- left: 0;
135
- display: inline-block;
136
- border: 1px solid var(--block-border-color);
137
- border-radius: var(--radius-md);
138
-
139
- transition: var(--button-transition);
140
- box-shadow: var(--button-shadow);
141
-
142
- text-align: left;
143
- border-bottom: none;
144
- border-left: none;
145
- border-bottom-right-radius: 0;
146
- border-top-left-radius: 0;
147
- background-color: var(--background-fill-primary);
148
- overflow: hidden;
160
+ position: relative;
161
+ display: flex;
162
+ justify-content: center;
163
+ align-items: center;
149
164
  }
150
165
 
151
166
  .layer-wrap button {
152
- display: inline-flex;
153
167
  justify-content: flex-start;
154
168
  align-items: flex-start;
155
- padding: var(--size-2) var(--size-4);
156
169
  width: 100%;
157
170
  border-bottom: 1px solid var(--block-border-color);
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: center;
174
+ font-size: var(--scale-000);
175
+ line-height: var(--line-sm);
176
+ padding-bottom: 1px;
177
+ margin-left: var(--spacing-xl);
178
+ padding: var(--spacing-sm) 0;
158
179
  }
159
180
 
160
181
  .layer-wrap li:last-child button {
161
182
  border-bottom: none;
162
183
  text-align: center;
184
+ font-size: var(--scale-0);
185
+ line-height: 1;
186
+ font-weight: var(--weight-bold);
187
+ padding: 5px 0 1px 0;
163
188
  }
164
189
 
165
190
  .closed > button {
@@ -167,12 +192,44 @@
167
192
  }
168
193
 
169
194
  .layer-wrap button:hover {
170
- background-color: var(--background-fill-secondary);
195
+ background-color: none;
196
+ }
197
+
198
+ .layer-wrap button:hover .icon {
199
+ color: var(--color-accent);
171
200
  }
172
201
 
173
202
  .selected_layer {
174
- background-color: var(--color-accent) !important;
175
- color: white;
203
+ background-color: var(--block-background-fill);
204
+ color: var(--color-accent);
176
205
  font-weight: bold;
177
206
  }
207
+
208
+ ul {
209
+ position: absolute;
210
+ bottom: 0;
211
+ left: 0;
212
+ background: var(--block-background-fill);
213
+ width: calc(100% + 1px);
214
+ list-style: none;
215
+ z-index: var(--layer-top);
216
+ border: 1px solid var(--block-border-color);
217
+ padding: var(--spacing-sm) 0;
218
+ text-wrap: none;
219
+ transform: translate(-1px, 1px);
220
+ border-radius: var(--radius-sm);
221
+ border-bottom-right-radius: 0;
222
+ }
223
+
224
+ .layer-wrap ul > li > button {
225
+ margin-left: 0;
226
+ }
227
+
228
+ .sep {
229
+ height: 12px;
230
+ background-color: var(--block-border-color);
231
+ width: 1px;
232
+ display: block;
233
+ margin-left: var(--spacing-xl);
234
+ }
178
235
  </style>
@@ -93,7 +93,8 @@ interface LayerManager {
93
93
  add_layer_from_blob(
94
94
  container: Container,
95
95
  renderer: IRenderer,
96
- blob: Blob
96
+ blob: Blob,
97
+ view: HTMLCanvasElement
97
98
  ): Promise<[LayerScene, LayerScene[]]>;
98
99
  }
99
100
 
@@ -197,19 +198,32 @@ export function layer_manager(): LayerManager {
197
198
  async add_layer_from_blob(
198
199
  container: Container,
199
200
  renderer: IRenderer,
200
- blob: Blob
201
+ blob: Blob,
202
+ view: HTMLCanvasElement
201
203
  ) {
202
204
  const img = await createImageBitmap(blob);
203
205
  const bitmap_texture = Texture.from(img);
206
+
207
+ const [w, h] = resize_to_fit(
208
+ bitmap_texture.width,
209
+ bitmap_texture.height,
210
+ view.width,
211
+ view.height
212
+ );
213
+
204
214
  const sprite = new Sprite(bitmap_texture) as Sprite & DisplayObject;
205
215
  sprite.zIndex = 0;
206
216
 
217
+ sprite.width = w;
218
+ sprite.height = h;
219
+
207
220
  const [layer, layers] = this.add_layer(
208
221
  container,
209
222
  renderer,
210
- sprite.width,
211
- sprite.height
223
+ view.width,
224
+ view.height
212
225
  );
226
+
213
227
  renderer.render(sprite, {
214
228
  renderTexture: layer.draw_texture
215
229
  });
@@ -221,3 +235,29 @@ export function layer_manager(): LayerManager {
221
235
  }
222
236
  };
223
237
  }
238
+
239
+ function resize_to_fit(
240
+ inner_width: number,
241
+ inner_height: number,
242
+ outer_width: number,
243
+ outer_height: number
244
+ ): [number, number] {
245
+ if (inner_width <= outer_width && inner_height <= outer_height) {
246
+ return [inner_width, inner_height];
247
+ }
248
+
249
+ const inner_aspect = inner_width / inner_height;
250
+ const outer_aspect = outer_width / outer_height;
251
+
252
+ let new_width, new_height;
253
+
254
+ if (inner_aspect > outer_aspect) {
255
+ new_width = outer_width;
256
+ new_height = outer_width / inner_aspect;
257
+ } else {
258
+ new_height = outer_height;
259
+ new_width = outer_height * inner_aspect;
260
+ }
261
+
262
+ return [new_width, new_height];
263
+ }
@@ -23,7 +23,7 @@
23
23
  color_mode: "fixed" | "defaults";
24
24
  }
25
25
 
26
- type brush_option_type = "color" | "size";
26
+ type brush_option_type = boolean;
27
27
  </script>
28
28
 
29
29
  <script lang="ts">
@@ -32,11 +32,9 @@
32
32
 
33
33
  import { getContext, onMount, tick } from "svelte";
34
34
  import { type ToolContext, TOOL_KEY } from "./Tools.svelte";
35
- import { Palette, BrushSize as SizeIcon } from "@gradio/icons";
36
35
  import { type EditorContext, EDITOR_KEY } from "../ImageEditor.svelte";
37
36
  import { draw_path, type DrawCommand } from "./brush";
38
- import BrushColor from "./BrushColor.svelte";
39
- import BrushSize from "./BrushSize.svelte";
37
+ import BrushOptions from "./BrushOptions.svelte";
40
38
  import type { FederatedPointerEvent } from "pixi.js";
41
39
 
42
40
  export let default_size: Brush["default_size"];
@@ -46,7 +44,7 @@
46
44
  export let mode: "erase" | "draw";
47
45
 
48
46
  const processed_colors = colors
49
- ? colors.map(process_color).filter((_, i) => i < 5)
47
+ ? colors.map(process_color).filter((_, i) => i < 4)
50
48
  : [];
51
49
 
52
50
  let selected_color =
@@ -56,32 +54,7 @@
56
54
  ? "black"
57
55
  : process_color(default_color);
58
56
 
59
- const paint_meta = {
60
- color: {
61
- icon: Palette,
62
- label: "Color",
63
- order: 0,
64
- id: "brush_color",
65
- cb() {
66
- current_option = "color";
67
- }
68
- },
69
- size: {
70
- icon: SizeIcon,
71
- label: "Size",
72
- order: 1,
73
- id: "brush_size",
74
- cb() {
75
- current_option = "size";
76
- }
77
- }
78
- } as const;
79
-
80
- let brush_options: (typeof paint_meta)[brush_option_type][];
81
- $: brush_options =
82
- mode === "draw" ? Object.values(paint_meta) : [paint_meta.size];
83
-
84
- let current_option: brush_option_type | null = null;
57
+ let brush_options: brush_option_type = false;
85
58
 
86
59
  const {
87
60
  pixi,
@@ -90,7 +63,8 @@
90
63
  command_manager,
91
64
  register_context,
92
65
  editor_box,
93
- crop
66
+ crop,
67
+ toolbar_box
94
68
  } = getContext<EditorContext>(EDITOR_KEY);
95
69
 
96
70
  const { active_tool, register_tool, current_color } =
@@ -216,8 +190,7 @@
216
190
 
217
191
  onMount(() => {
218
192
  const unregister = register_tool(mode, {
219
- default: null,
220
- options: brush_options
193
+ cb: toggle_options
221
194
  });
222
195
 
223
196
  return () => {
@@ -232,19 +205,37 @@
232
205
  return tinycolor(color).toRgbString();
233
206
  }
234
207
 
235
- $: {
236
- if ($active_tool !== mode) {
237
- current_option = null;
238
- }
239
- }
240
-
241
208
  let pos = { x: 0, y: 0 };
242
209
  $: brush_size =
243
210
  (selected_size / $dimensions[0]) * $editor_box.child_width * 2;
211
+
212
+ function debounce_toggle(): (should_close?: boolean) => void {
213
+ let timeout: NodeJS.Timeout | null = null;
214
+
215
+ return function executedFunction(should_close?: boolean) {
216
+ const later = (): void => {
217
+ if (timeout) {
218
+ clearTimeout(timeout);
219
+ }
220
+ if (should_close !== undefined) {
221
+ brush_options = should_close;
222
+ return;
223
+ }
224
+ brush_options = !brush_options;
225
+ };
226
+
227
+ if (timeout) {
228
+ clearTimeout(timeout);
229
+ }
230
+ timeout = setTimeout(later, 100);
231
+ };
232
+ }
233
+
234
+ const toggle_options = debounce_toggle();
244
235
  </script>
245
236
 
246
237
  <svelte:window
247
- on:keydown={({ key }) => key === "Escape" && (current_option = null)}
238
+ on:keydown={({ key }) => key === "Escape" && toggle_options(false)}
248
239
  />
249
240
 
250
241
  <span
@@ -260,23 +251,23 @@
260
251
  style:opacity={brush_cursor ? 1 : 0}
261
252
  />
262
253
 
263
- {#if current_option === "color" && colors}
254
+ {#if brush_options}
264
255
  <div>
265
- <BrushColor
266
- on:click_outside={() => (current_option = null)}
256
+ <BrushOptions
257
+ show_swatch={mode === "draw"}
258
+ on:click_outside={() => toggle_options()}
267
259
  colors={processed_colors}
268
260
  bind:selected_color
269
261
  {color_mode}
270
262
  bind:recent_colors
263
+ bind:selected_size
264
+ dimensions={$dimensions}
265
+ parent_width={$editor_box.parent_width}
266
+ parent_height={$editor_box.parent_height}
267
+ parent_left={$editor_box.parent_left}
268
+ toolbar_box={$toolbar_box}
271
269
  />
272
270
  </div>
273
- {:else if current_option === "size"}
274
- <BrushSize
275
- on:click_outside={() => (current_option = null)}
276
- max={$dimensions[0] / 10}
277
- min={1}
278
- bind:selected_size
279
- />
280
271
  {/if}
281
272
 
282
273
  <style>
@@ -6,11 +6,19 @@
6
6
  import ColorPicker from "./ColorPicker.svelte";
7
7
  import ColorSwatch from "./ColorSwatch.svelte";
8
8
  import ColorField from "./ColorField.svelte";
9
+ import BrushSize from "./BrushSize.svelte";
9
10
 
10
11
  export let colors: string[];
11
12
  export let selected_color: string;
12
13
  export let color_mode: Brush["color_mode"] | undefined = undefined;
13
14
  export let recent_colors: (string | null)[] = [];
15
+ export let selected_size: number;
16
+ export let dimensions: [number, number];
17
+ export let parent_width: number;
18
+ export let parent_height: number;
19
+ export let parent_left: number;
20
+ export let toolbar_box: DOMRect;
21
+ export let show_swatch: boolean;
14
22
 
15
23
  let color_picker = false;
16
24
  let current_mode: "hex" | "rgb" | "hsl" = "hex";
@@ -54,15 +62,29 @@
54
62
  let c_width = 0;
55
63
  let c_height = 0;
56
64
  let wrap_el: HTMLDivElement;
57
- let anchor_right = false;
58
- let anchor_top = false;
59
-
65
+ let full_screen = false;
66
+ let anchor: "default" | "center" | "top" = "default";
67
+ let left_anchor = 0;
68
+ let top_anchor = null;
60
69
  $: {
61
70
  if (wrap_el && (width || height || c_height || c_width)) {
62
71
  const box = wrap_el.getBoundingClientRect();
63
-
64
- anchor_right = box.width + 30 > width / 2;
65
- anchor_top = box.y < 80;
72
+ color_picker;
73
+
74
+ if (box.width > parent_width) {
75
+ full_screen = true;
76
+ } else if (parent_width > box.width + 230 * 2 + 25) {
77
+ anchor = "default";
78
+
79
+ left_anchor = toolbar_box.right - parent_left + 24;
80
+ } else {
81
+ anchor = "center";
82
+ }
83
+
84
+ if (box.height + 50 > parent_height) {
85
+ anchor = "top";
86
+ top_anchor = true;
87
+ }
66
88
  }
67
89
  }
68
90
  </script>
@@ -73,12 +95,13 @@
73
95
  class="wrap"
74
96
  class:padded={!color_picker}
75
97
  use:click_outside={() => dispatch("click_outside")}
76
- class:right={anchor_right}
77
- class:top={anchor_top}
78
- class:bottom={!anchor_top}
98
+ class:anchor_default={anchor === "default"}
99
+ class:anchor_center={anchor === "center"}
100
+ class:anchor_top={anchor === "top"}
79
101
  bind:this={wrap_el}
80
102
  bind:clientWidth={c_width}
81
103
  bind:clientHeight={c_height}
104
+ class:anchor={`${left_anchor}px`}
82
105
  >
83
106
  {#if color_mode === "defaults"}
84
107
  {#if color_picker}
@@ -91,49 +114,67 @@
91
114
  />
92
115
  {/if}
93
116
  {/if}
94
- <ColorSwatch
95
- bind:color_picker
96
- {colors}
97
- on:select={({ detail }) => handle_color_selection(detail, "core")}
98
- on:edit={({ detail }) => handle_color_selection(detail, "user")}
99
- user_colors={color_mode === "defaults" ? recent_colors : null}
100
- {selected_color}
101
- {current_mode}
102
- />
117
+ {#if show_swatch}
118
+ <ColorSwatch
119
+ bind:color_picker
120
+ {colors}
121
+ on:select={({ detail }) => handle_color_selection(detail, "core")}
122
+ on:edit={({ detail }) => handle_color_selection(detail, "user")}
123
+ user_colors={color_mode === "defaults" ? recent_colors : null}
124
+ {selected_color}
125
+ {current_mode}
126
+ />
127
+ {/if}
128
+
129
+ {#if color_picker || show_swatch}
130
+ <div class="sep"></div>
131
+ {/if}
132
+
133
+ <BrushSize max={dimensions[0] / 10} min={1} bind:selected_size />
103
134
  </div>
104
135
 
105
136
  <style>
106
137
  .wrap {
107
- width: 230px;
138
+ max-width: 230px;
139
+ width: 90%;
108
140
  position: absolute;
109
141
  display: flex;
110
142
  flex-direction: column;
111
143
  gap: 5px;
112
144
  background: var(--background-fill-secondary);
113
145
  border: 1px solid var(--block-border-color);
114
-
115
146
  border-radius: var(--radius-md);
116
- box-shadow:
117
- 0 0 5px rgba(0, 0, 0, 0.1),
118
- 0 5px 30px rgba(0, 0, 0, 0.2);
147
+ box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.1);
119
148
  padding-bottom: var(--size-2);
120
149
  pointer-events: all;
121
150
  cursor: default;
122
151
  z-index: var(--layer-top);
152
+ overflow: hidden;
123
153
  }
124
154
 
125
- .bottom {
126
- bottom: 85px;
155
+ .anchor_default {
156
+ position: absolute;
157
+ left: var(--left-anchor);
158
+ bottom: 8px;
127
159
  }
128
-
129
- .top {
130
- top: 30px;
160
+ .anchor_center {
161
+ position: absolute;
162
+ bottom: 50px;
163
+ left: 50%;
164
+ transform: translateX(-50%);
131
165
  }
132
166
 
133
- .right {
134
- right: 10px;
167
+ .anchor_top {
168
+ position: absolute;
169
+ top: 1px;
170
+ left: 50%;
171
+ transform: translateX(-50%);
135
172
  }
136
- .padded {
137
- padding-top: var(--size-3);
173
+
174
+ .sep {
175
+ height: 1px;
176
+ background-color: var(--block-border-color);
177
+ margin: 0 var(--size-3);
178
+ margin-top: var(--size-1);
138
179
  }
139
180
  </style>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { click_outside } from "../utils/events";
3
3
  import { createEventDispatcher } from "svelte";
4
+ import { BrushSize } from "@gradio/icons";
4
5
 
5
6
  export let selected_size: number;
6
7
  export let min: number;
@@ -40,36 +41,28 @@
40
41
  class:top={anchor_top}
41
42
  class:bottom={!anchor_top}
42
43
  >
44
+ <span>
45
+ <BrushSize />
46
+ </span>
43
47
  <input type="range" bind:value={selected_size} {min} {max} step={1} />
44
48
  </div>
45
49
 
46
50
  <style>
47
51
  .wrap {
48
- width: 180px;
49
- position: absolute;
52
+ width: 100%;
50
53
  display: flex;
51
- flex-direction: column;
52
- gap: 5px;
54
+ gap: var(--size-4);
53
55
  background: var(--background-fill-secondary);
54
- border: 1px solid var(--block-border-color);
55
-
56
- border-radius: var(--radius-md);
57
- box-shadow:
58
- 0 0 5px rgba(0, 0, 0, 0.1),
59
- 0 5px 30px rgba(0, 0, 0, 0.2);
60
- padding: var(--size-2);
56
+ padding: 0 var(--size-4);
61
57
  cursor: default;
58
+ padding-top: var(--size-2-5);
62
59
  }
63
60
 
64
- .bottom {
65
- bottom: 85px;
66
- }
67
-
68
- .top {
69
- top: 30px;
61
+ input {
62
+ width: 100%;
70
63
  }
71
-
72
- .right {
73
- right: 10px;
64
+ span {
65
+ width: 26px;
66
+ color: var(--body-text-color);
74
67
  }
75
68
  </style>