@gradio/imageeditor 0.1.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.
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@gradio/imageeditor",
3
+ "version": "0.1.0",
4
+ "description": "Gradio UI packages",
5
+ "type": "module",
6
+ "author": "",
7
+ "license": "ISC",
8
+ "private": false,
9
+ "dependencies": {
10
+ "@types/tinycolor2": "^1.4.6",
11
+ "pixi.js": "^7.3.2",
12
+ "tinycolor2": "^1.6.0",
13
+ "@gradio/atoms": "^0.3.0",
14
+ "@gradio/icons": "^0.3.0",
15
+ "@gradio/client": "^0.8.1",
16
+ "@gradio/image": "^0.4.0",
17
+ "@gradio/upload": "^0.5.0",
18
+ "@gradio/statustracker": "^0.4.0",
19
+ "@gradio/wasm": "^0.3.0",
20
+ "@gradio/utils": "^0.2.0"
21
+ },
22
+ "main_changeset": true,
23
+ "main": "./Index.svelte",
24
+ "exports": {
25
+ ".": "./Index.svelte",
26
+ "./example": "./Example.svelte",
27
+ "./package.json": "./package.json"
28
+ }
29
+ }
@@ -0,0 +1,104 @@
1
+ <script lang="ts">
2
+ import { createEventDispatcher } from "svelte";
3
+ import { IconButton } from "@gradio/atoms";
4
+ import { Clear, Undo, Redo, Check } from "@gradio/icons";
5
+
6
+ /**
7
+ * Can the current image be undone?
8
+ */
9
+ export let can_undo = false;
10
+ /**
11
+ * Can the current image be redone?
12
+ */
13
+ export let can_redo = false;
14
+
15
+ export let can_save = false;
16
+ export let changeable = false;
17
+
18
+ const dispatch = createEventDispatcher<{
19
+ /**
20
+ * Remove the current image.
21
+ */
22
+ remove_image: void;
23
+ /**
24
+ * Undo the last action.
25
+ */
26
+ undo: void;
27
+ /**
28
+ * Redo the last action.
29
+ */
30
+ redo: void;
31
+ /**
32
+ * Save the current image.
33
+ */
34
+ save: void;
35
+ }>();
36
+ </script>
37
+
38
+ <div class="controls-wrap">
39
+ <div class="row-wrap">
40
+ <IconButton
41
+ disabled={!can_undo}
42
+ Icon={Undo}
43
+ label="Undo"
44
+ on:click={(event) => {
45
+ dispatch("undo");
46
+ event.stopPropagation();
47
+ }}
48
+ />
49
+ <IconButton
50
+ disabled={!can_redo}
51
+ Icon={Redo}
52
+ label="Redo"
53
+ on:click={(event) => {
54
+ dispatch("redo");
55
+ event.stopPropagation();
56
+ }}
57
+ />
58
+ <IconButton
59
+ Icon={Clear}
60
+ label="Clear canvas"
61
+ on:click={(event) => {
62
+ dispatch("remove_image");
63
+ event.stopPropagation();
64
+ }}
65
+ />
66
+ </div>
67
+ {#if changeable}
68
+ <div class="row-wrap save">
69
+ <IconButton
70
+ disabled={!can_save}
71
+ Icon={Check}
72
+ label="Save changes"
73
+ on:click={(event) => {
74
+ dispatch("save");
75
+ event.stopPropagation();
76
+ }}
77
+ background={"var(--color-green-500)"}
78
+ color={"#fff"}
79
+ />
80
+ </div>
81
+ {/if}
82
+ </div>
83
+
84
+ <style>
85
+ .controls-wrap {
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: var(--spacing-lg);
89
+ position: absolute;
90
+ top: var(--size-2);
91
+ right: var(--size-2);
92
+ }
93
+ .row-wrap {
94
+ display: flex;
95
+
96
+ justify-content: flex-end;
97
+ gap: var(--spacing-sm);
98
+ z-index: var(--layer-5);
99
+ }
100
+
101
+ .save :global(svg) {
102
+ transform: translateY(1px);
103
+ }
104
+ </style>
@@ -0,0 +1,375 @@
1
+ <script lang="ts" context="module">
2
+ import type { Writable, Readable } from "svelte/store";
3
+ import type { Spring } from "svelte/motion";
4
+ import { type PixiApp } from "./utils/pixi";
5
+ import { type CommandManager } from "./utils/commands";
6
+
7
+ export const EDITOR_KEY = Symbol("editor");
8
+ export type context_type = "bg" | "layers" | "crop" | "draw" | "erase";
9
+ type PartialRecord<K extends keyof any, T> = Partial<Record<K, T>>;
10
+ import { type tool } from "./tools";
11
+
12
+ export interface EditorContext {
13
+ pixi: Writable<PixiApp | null>;
14
+ current_layer: Writable<LayerScene | null>;
15
+ dimensions: Writable<[number, number]>;
16
+ editor_box: Writable<{
17
+ parent_width: number;
18
+ parent_height: number;
19
+ parent_left: number;
20
+ parent_top: number;
21
+ parent_right: number;
22
+ parent_bottom: number;
23
+ child_width: number;
24
+ child_height: number;
25
+ child_left: number;
26
+ child_top: number;
27
+ child_right: number;
28
+ child_bottom: number;
29
+ }>;
30
+ active_tool: Writable<tool>;
31
+ crop: Writable<[number, number, number, number]>;
32
+ position_spring: Spring<{
33
+ x: number;
34
+ y: number;
35
+ }>;
36
+ command_manager: CommandManager;
37
+ current_history: CommandManager["current_history"];
38
+ register_context: (
39
+ type: context_type,
40
+ {
41
+ reset_fn,
42
+ init_fn
43
+ }: {
44
+ reset_fn?: () => void;
45
+ init_fn?: (dimensions?: [number, number]) => void;
46
+ }
47
+ ) => void;
48
+ reset: (clear_image: boolean, dimensions: [number, number]) => void;
49
+ }
50
+ </script>
51
+
52
+ <script lang="ts">
53
+ import { onMount, setContext, createEventDispatcher, tick } from "svelte";
54
+ import { writable } from "svelte/store";
55
+ import { spring } from "svelte/motion";
56
+ import { Rectangle } from "pixi.js";
57
+
58
+ import { command_manager } from "./utils/commands";
59
+
60
+ import { type LayerScene } from "./layers/utils";
61
+ import { create_pixi_app, type ImageBlobs } from "./utils/pixi";
62
+ import Controls from "./Controls.svelte";
63
+ export let antialias = true;
64
+ export let crop_size: [number, number] = [800, 600];
65
+ export let changeable = false;
66
+ export let history: boolean;
67
+ export let bg = false;
68
+ export let sources: ("clipboard" | "webcam" | "upload")[];
69
+ const dispatch = createEventDispatcher<{
70
+ save: void;
71
+ }>();
72
+ export let crop_constraint = false;
73
+
74
+ let dimensions = writable(crop_size);
75
+
76
+ let editor_box: EditorContext["editor_box"] = writable({
77
+ parent_width: 0,
78
+ parent_height: 0,
79
+ parent_top: 0,
80
+ parent_left: 0,
81
+ parent_right: 0,
82
+ parent_bottom: 0,
83
+ child_width: 0,
84
+ child_height: 0,
85
+ child_top: 0,
86
+ child_left: 0,
87
+ child_right: 0,
88
+ child_bottom: 0
89
+ });
90
+
91
+ const crop = writable<[number, number, number, number]>([0, 0, 1, 1]);
92
+ const position_spring = spring(
93
+ { x: 0, y: 0 },
94
+ {
95
+ stiffness: 0.1,
96
+ damping: 0.5
97
+ }
98
+ );
99
+ const pixi = writable<PixiApp | null>(null);
100
+
101
+ const CommandManager = command_manager();
102
+
103
+ const { can_redo, can_undo, current_history } = CommandManager;
104
+
105
+ $: {
106
+ history = !!$current_history.previous || $active_tool !== "bg";
107
+ }
108
+
109
+ const active_tool: Writable<tool> = writable("bg");
110
+ const reset_context: Writable<PartialRecord<context_type, () => void>> =
111
+ writable({});
112
+ const init_context: Writable<
113
+ PartialRecord<context_type, (dimensions?: typeof $dimensions) => void>
114
+ > = writable({});
115
+ const contexts: Writable<context_type[]> = writable([]);
116
+
117
+ const sort_order = ["bg", "layers", "crop", "draw", "erase"] as const;
118
+ const editor_context = setContext<EditorContext>(EDITOR_KEY, {
119
+ pixi,
120
+ current_layer: writable(null),
121
+ dimensions,
122
+ editor_box,
123
+ active_tool,
124
+ crop,
125
+ position_spring,
126
+ command_manager: CommandManager,
127
+ current_history,
128
+ register_context: (
129
+ type: context_type,
130
+ {
131
+ reset_fn,
132
+ init_fn
133
+ }: {
134
+ reset_fn?: () => void;
135
+ init_fn?: (dimensions?: [number, number]) => void;
136
+ }
137
+ ) => {
138
+ contexts.update((c) => [...c, type]);
139
+ init_context.update((c) => ({ ...c, [type]: init_fn }));
140
+ reset_context.update((c) => ({ ...c, [type]: reset_fn }));
141
+ },
142
+ reset: (clear_image: boolean, dimensions: [number, number]) => {
143
+ bg = false;
144
+
145
+ const _sorted_contexts = $contexts.sort((a, b) => {
146
+ return sort_order.indexOf(a) - sort_order.indexOf(b);
147
+ });
148
+ for (const k of _sorted_contexts) {
149
+ if (k in $reset_context && typeof $reset_context[k] === "function") {
150
+ $reset_context[k]?.();
151
+ }
152
+ }
153
+
154
+ for (const k of _sorted_contexts) {
155
+ if (k in $init_context && typeof $init_context[k] === "function") {
156
+ if (k === "bg" && !clear_image) {
157
+ continue;
158
+ } else {
159
+ $init_context[k]?.(dimensions);
160
+ }
161
+ }
162
+ CommandManager.reset();
163
+ $pixi?.resize?.(...dimensions);
164
+ }
165
+ }
166
+ });
167
+
168
+ let pixi_target: HTMLDivElement;
169
+
170
+ let canvas_wrap: HTMLDivElement;
171
+
172
+ function get_dimensions(parent: HTMLDivElement, child: HTMLDivElement): void {
173
+ if (!parent || !child) return;
174
+ const {
175
+ width: parent_width,
176
+ height: parent_height,
177
+ top: parent_top,
178
+ left: parent_left,
179
+ right: parent_right,
180
+ bottom: parent_bottom
181
+ } = canvas_wrap.getBoundingClientRect();
182
+ const {
183
+ width: child_width,
184
+ height: child_height,
185
+ top: child_top,
186
+ left: child_left,
187
+ right: child_right,
188
+ bottom: child_bottom
189
+ } = child.getBoundingClientRect();
190
+ editor_box.set({
191
+ child_width,
192
+ child_height,
193
+ child_left,
194
+ child_right,
195
+ child_top,
196
+ child_bottom,
197
+
198
+ parent_width,
199
+ parent_height,
200
+ parent_left,
201
+ parent_right,
202
+ parent_top,
203
+ parent_bottom
204
+ });
205
+ }
206
+
207
+ $: if (crop_constraint && bg && !history) {
208
+ set_crop();
209
+ }
210
+
211
+ function set_crop(): void {
212
+ requestAnimationFrame(() => {
213
+ tick().then((v) => ($active_tool = "crop"));
214
+ });
215
+ }
216
+
217
+ function reposition_canvas(): void {
218
+ if (!$editor_box) return;
219
+ const [l, t, w, h] = $crop;
220
+
221
+ const cx = l * $editor_box.child_width;
222
+ const cy = t * $editor_box.child_height;
223
+ const cw = w * $editor_box.child_width;
224
+ const ch = h * $editor_box.child_height;
225
+
226
+ const x = 0.5 * $editor_box.child_width - cx - cw / 2;
227
+ const y = 0.5 * $editor_box.child_height - cy - ch / 2;
228
+
229
+ position_spring.set({ x, y });
230
+ }
231
+
232
+ export async function get_blobs(): Promise<ImageBlobs> {
233
+ if (!$pixi || !$pixi.get_layers)
234
+ return { background: null, layers: [], composite: null };
235
+ const [l, t, w, h] = $crop;
236
+
237
+ return $pixi?.get_blobs(
238
+ $pixi.get_layers(),
239
+ new Rectangle(
240
+ l * $dimensions[0],
241
+ t * $dimensions[1],
242
+ w * $dimensions[0],
243
+ h * $dimensions[1]
244
+ ),
245
+ $dimensions
246
+ );
247
+ }
248
+
249
+ $: $crop && reposition_canvas();
250
+ $: $position_spring && get_dimensions(canvas_wrap, pixi_target);
251
+
252
+ export function handle_remove(): void {
253
+ editor_context.reset(true, $dimensions);
254
+ if (!sources.length) {
255
+ set_tool("draw");
256
+ } else {
257
+ set_tool("bg");
258
+ }
259
+ }
260
+
261
+ onMount(() => {
262
+ const app = create_pixi_app(pixi_target, ...crop_size, antialias);
263
+
264
+ function resize(width: number, height: number): void {
265
+ app.resize(width, height);
266
+ dimensions.set([width, height]);
267
+ }
268
+
269
+ pixi.set({ ...app, resize });
270
+
271
+ const resizer = new ResizeObserver((entries) => {
272
+ for (const entry of entries) {
273
+ get_dimensions(canvas_wrap, pixi_target);
274
+ }
275
+ });
276
+
277
+ resizer.observe(canvas_wrap);
278
+ resizer.observe(pixi_target);
279
+
280
+ for (const k of $contexts) {
281
+ if (k in $init_context && typeof $init_context[k] === "function") {
282
+ $init_context[k]?.($dimensions);
283
+ }
284
+ }
285
+
286
+ resize(...$dimensions);
287
+
288
+ return () => {
289
+ $pixi?.destroy();
290
+ resizer.disconnect();
291
+ for (const k of $contexts) {
292
+ if (k in $reset_context) {
293
+ $reset_context[k]?.();
294
+ }
295
+ }
296
+ };
297
+ });
298
+
299
+ let saved_history: null | typeof $current_history = $current_history;
300
+
301
+ function handle_save(): void {
302
+ saved_history = $current_history;
303
+ dispatch("save");
304
+ }
305
+
306
+ export function set_tool(tool: tool): void {
307
+ $active_tool = tool;
308
+ }
309
+ </script>
310
+
311
+ <svelte:window on:scroll={() => get_dimensions(canvas_wrap, pixi_target)} />
312
+
313
+ <div data-testid="image" class="image-container">
314
+ <Controls
315
+ can_undo={$can_undo}
316
+ can_redo={$can_redo}
317
+ can_save={saved_history !== $current_history}
318
+ {changeable}
319
+ on:undo={CommandManager.undo}
320
+ on:redo={CommandManager.redo}
321
+ on:remove_image={handle_remove}
322
+ on:save={handle_save}
323
+ />
324
+ <div class="wrap" bind:this={canvas_wrap}>
325
+ <div
326
+ bind:this={pixi_target}
327
+ class="stage-wrap"
328
+ class:bg={!bg}
329
+ style:transform="translate({$position_spring.x}px, {$position_spring.y}px)"
330
+ ></div>
331
+ </div>
332
+ <slot />
333
+ <div
334
+ class="border"
335
+ style:width="{$crop[2] * $editor_box.child_width}px"
336
+ style:height="{$crop[3] * $editor_box.child_height}px"
337
+ style:top="{$crop[1] * $editor_box.child_height +
338
+ ($editor_box.child_top - $editor_box.parent_top)}px"
339
+ style:left="{$crop[0] * $editor_box.child_width +
340
+ ($editor_box.child_left - $editor_box.parent_left)}px"
341
+ ></div>
342
+ </div>
343
+
344
+ <style>
345
+ .wrap {
346
+ width: 100%;
347
+ height: 100%;
348
+ position: relative;
349
+ display: flex;
350
+ justify-content: center;
351
+ align-items: center;
352
+ }
353
+ .border {
354
+ position: absolute;
355
+ border: var(--block-border-color) 1px solid;
356
+ pointer-events: none;
357
+ }
358
+
359
+ .stage-wrap {
360
+ margin: var(--size-8);
361
+ margin-bottom: var(--size-1);
362
+ }
363
+
364
+ .bg {
365
+ }
366
+
367
+ .image-container {
368
+ display: flex;
369
+ height: 100%;
370
+ flex-direction: column;
371
+ justify-content: center;
372
+ align-items: center;
373
+ max-height: 100%;
374
+ }
375
+ </style>
@@ -0,0 +1,211 @@
1
+ <script lang="ts" context="module">
2
+ export interface EditorData {
3
+ background: FileData | null;
4
+ layers: FileData[] | null;
5
+ composite: FileData | null;
6
+ }
7
+
8
+ export interface ImageBlobs {
9
+ background: FileData | null;
10
+ layers: FileData[];
11
+ composite: FileData | null;
12
+ }
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ import { type I18nFormatter } from "@gradio/utils";
17
+ import {
18
+ prepare_files,
19
+ upload,
20
+ normalise_file,
21
+ type FileData
22
+ } from "@gradio/client";
23
+
24
+ import ImageEditor from "./ImageEditor.svelte";
25
+ import Layers from "./layers/Layers.svelte";
26
+ import { type Brush as IBrush } from "./tools/Brush.svelte";
27
+ import { type Eraser } from "./tools/Brush.svelte";
28
+
29
+ export let brush: IBrush | null;
30
+ export let eraser: Eraser | null;
31
+ import { Tools, Crop, Brush, Sources } from "./tools";
32
+
33
+ export let sources: ("clipboard" | "webcam" | "upload")[];
34
+ export let crop_size: [number, number] | `${string}:${string}` | null = null;
35
+ export let i18n: I18nFormatter;
36
+ export let root: string;
37
+ export let proxy_url: string;
38
+ export let changeable = false;
39
+ export let value: EditorData | null = {
40
+ background: null,
41
+ layers: [],
42
+ composite: null
43
+ };
44
+ export let transforms: "crop"[] = ["crop"];
45
+
46
+ let editor: ImageEditor;
47
+
48
+ function is_not_null(o: Blob | null): o is Blob {
49
+ return !!o;
50
+ }
51
+
52
+ function is_file_data(o: null | FileData): o is FileData {
53
+ return !!o;
54
+ }
55
+
56
+ export async function get_data(): Promise<ImageBlobs> {
57
+ const blobs = await editor.get_blobs();
58
+
59
+ const bg = blobs.background
60
+ ? upload(
61
+ await prepare_files([new File([blobs.background], "background.png")]),
62
+ root
63
+ )
64
+ : Promise.resolve(null);
65
+
66
+ const layers = blobs.layers
67
+ .filter(is_not_null)
68
+ .map(async (blob, i) =>
69
+ upload(await prepare_files([new File([blob], `layer_${i}.png`)]), root)
70
+ );
71
+
72
+ const composite = blobs.composite
73
+ ? upload(
74
+ await prepare_files([new File([blobs.composite], "composite.png")]),
75
+ root
76
+ )
77
+ : Promise.resolve(null);
78
+
79
+ const [background, composite_, ...layers_] = await Promise.all([
80
+ bg,
81
+ composite,
82
+ ...layers
83
+ ]);
84
+
85
+ return {
86
+ background: Array.isArray(background) ? background[0] : background,
87
+ layers: layers_
88
+ .flatMap((layer) => (Array.isArray(layer) ? layer : [layer]))
89
+ .filter(is_file_data),
90
+ composite: Array.isArray(composite_) ? composite_[0] : composite_
91
+ };
92
+ }
93
+
94
+ function handle_value(value: EditorData | null): void {
95
+ if (!editor) return;
96
+ if (value == null) {
97
+ editor.handle_remove();
98
+ }
99
+ }
100
+
101
+ $: handle_value(value);
102
+
103
+ $: crop_constraint = crop_size;
104
+ let bg = false;
105
+ let history = false;
106
+
107
+ $: editor &&
108
+ editor.set_tool &&
109
+ (sources && sources.length
110
+ ? editor.set_tool("bg")
111
+ : editor.set_tool("draw"));
112
+ </script>
113
+
114
+ <ImageEditor
115
+ bind:this={editor}
116
+ {changeable}
117
+ on:save
118
+ bind:history
119
+ bind:bg
120
+ {sources}
121
+ crop_constraint={!!crop_constraint}
122
+ >
123
+ <Tools {i18n}>
124
+ {#if sources && sources.length}
125
+ <Sources
126
+ {i18n}
127
+ {root}
128
+ {sources}
129
+ bind:bg
130
+ background_file={normalise_file(
131
+ value?.background || null,
132
+ root,
133
+ proxy_url
134
+ )}
135
+ ></Sources>
136
+ {/if}
137
+ {#if transforms.includes("crop")}
138
+ <Crop {crop_constraint} />
139
+ {/if}
140
+ {#if brush}
141
+ <Brush
142
+ color_mode={brush.color_mode}
143
+ default_color={brush.default_color}
144
+ default_size={brush.default_size}
145
+ colors={brush.colors}
146
+ mode="draw"
147
+ />
148
+ {/if}
149
+
150
+ {#if brush && eraser}
151
+ <Brush default_size={eraser.default_size} mode="erase" />
152
+ {/if}
153
+ </Tools>
154
+
155
+ <Layers
156
+ layer_files={normalise_file(value?.layers || null, root, proxy_url)}
157
+ />
158
+
159
+ {#if !bg && !history}
160
+ <div class="empty wrap">
161
+ {#if sources && sources.length}
162
+ <div>Upload an image</div>
163
+ {/if}
164
+
165
+ {#if sources && sources.length && brush}
166
+ <div class="or">or</div>
167
+ {/if}
168
+ {#if brush}
169
+ <div>select the draw tool to start</div>
170
+ {/if}
171
+ </div>
172
+ {/if}
173
+ </ImageEditor>
174
+
175
+ <style>
176
+ .empty {
177
+ display: flex;
178
+ flex-direction: column;
179
+ justify-content: center;
180
+ align-items: center;
181
+
182
+ position: absolute;
183
+ height: 100%;
184
+ width: 100%;
185
+ left: 0;
186
+ right: 0;
187
+ margin: auto;
188
+ z-index: var(--layer-top);
189
+ text-align: center;
190
+ color: var(--body-text-color);
191
+ }
192
+
193
+ .wrap {
194
+ display: flex;
195
+ flex-direction: column;
196
+ justify-content: center;
197
+ align-items: center;
198
+ min-height: var(--size-60);
199
+ color: var(--block-label-text-color);
200
+ line-height: var(--line-md);
201
+ height: 100%;
202
+ padding-top: var(--size-3);
203
+ font-size: var(--text-lg);
204
+ pointer-events: none;
205
+ transform: translateY(-30px);
206
+ }
207
+
208
+ .or {
209
+ color: var(--body-text-color-subdued);
210
+ }
211
+ </style>