Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement zoom & pan logic in gr.ImageEditor #9155

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dark-items-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@gradio/icons": minor
"@gradio/imageeditor": minor
"gradio": minor
---

feat:Implement zoom & pan logic in `gr.ImageEditor`
19 changes: 19 additions & 0 deletions js/icons/src/ZoomIn.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<svg
viewBox="0 0 24 24"
stroke-width="2"
width="100%"
height="100%"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
><path
d="M8 11H11M14 11H11M11 11V8M11 11V14"
stroke-linecap="round"
stroke-linejoin="round"
></path><path d="M17 17L21 21" stroke-linecap="round" stroke-linejoin="round"
></path><path
d="M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>
16 changes: 16 additions & 0 deletions js/icons/src/ZoomOut.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<svg
width="100%"
height="100%"
viewBox="0 0 24 24"
stroke-width="2"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
><path d="M17 17L21 21" stroke-linecap="round" stroke-linejoin="round"
></path><path
d="M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z"
stroke-linecap="round"
stroke-linejoin="round"
></path><path d="M8 11L14 11" stroke-linecap="round" stroke-linejoin="round"
></path></svg
>
2 changes: 2 additions & 0 deletions js/icons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,5 @@ export { default as VolumeHigh } from "./VolumeHigh.svelte";
export { default as VolumeMuted } from "./VolumeMuted.svelte";
export { default as Warning } from "./Warning.svelte";
export { default as Webcam } from "./Webcam.svelte";
export { default as ZoomIn } from "./ZoomIn.svelte";
export { default as ZoomOut } from "./ZoomOut.svelte";
5 changes: 5 additions & 0 deletions js/imageeditor/shared/Controls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
position: absolute;
top: var(--size-2);
right: var(--size-2);
background: var(--block-background-fill);
}
.row-wrap {
display: flex;
Expand All @@ -97,4 +98,8 @@
gap: var(--spacing-sm);
z-index: var(--layer-5);
}

.row-wrap :global(button) {
background-color: var(--block-background-fill);
}
</style>
48 changes: 46 additions & 2 deletions js/imageeditor/shared/ImageEditor.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" context="module">
import type { Writable, Readable } from "svelte/store";
import type { Writable } from "svelte/store";
import type { Spring } from "svelte/motion";
import { type PixiApp } from "./utils/pixi";
import { type CommandManager } from "./utils/commands";
Expand Down Expand Up @@ -47,6 +47,9 @@
}
) => void;
reset: (clear_image: boolean, dimensions: [number, number]) => void;
zoom_in: () => void;
zoom_out: () => void;
reset_zoom_pan: () => void;
}
</script>

Expand All @@ -57,10 +60,12 @@
import { Rectangle } from "pixi.js";

import { command_manager } from "./utils/commands";
import { init_zoom_pan } from "./utils/zoom_pan";

import { type LayerScene } from "./layers/utils";
import { create_pixi_app, type ImageBlobs } from "./utils/pixi";
import Controls from "./Controls.svelte";
import ZoomControls from "./tools/ZoomControls.svelte";
export let antialias = true;
export let crop_size: [number, number] | undefined;
export let changeable = false;
Expand All @@ -75,6 +80,30 @@
export let crop_constraint = false;
export let canvas_size: [number, number] | undefined;

const zoom_pan_logic = init_zoom_pan();
const {
zoom_pan_state,
reset_zoom_pan,
handle_wheel,
zoom_to_point,
zoom_in,
zoom_out
} = zoom_pan_logic;

function handle_canvas_wheel(event: WheelEvent): void {
handle_wheel(event);
}

function handle_zoom_in(): void {
const rect = pixi_target.getBoundingClientRect();
zoom_to_point($zoom_pan_state.scale * 1.5, rect.width / 2, rect.height / 2);
}

function handle_zoom_out(): void {
const rect = pixi_target.getBoundingClientRect();
zoom_to_point($zoom_pan_state.scale / 1.5, rect.width / 2, rect.height / 2);
}

$: orig_canvas_size = canvas_size;

const BASE_DIMENSIONS: [number, number] = canvas_size || [800, 600];
Expand Down Expand Up @@ -140,6 +169,9 @@
position_spring,
command_manager: CommandManager,
current_history,
zoom_in: handle_zoom_in,
zoom_out: handle_zoom_out,
reset_zoom_pan,
register_context: (
type: context_type,
{
Expand Down Expand Up @@ -278,6 +310,10 @@
}

onMount(() => {
canvas_wrap.addEventListener("wheel", handle_canvas_wheel, {
passive: false
});

const _size = (canvas_size ? canvas_size : crop_size) || [800, 600];
const app = create_pixi_app({
target: pixi_target,
Expand Down Expand Up @@ -310,6 +346,8 @@
resize(...$dimensions);

return () => {
canvas_wrap.removeEventListener("wheel", handle_canvas_wheel);

$pixi?.destroy();
resizer.disconnect();
for (const k of $contexts) {
Expand Down Expand Up @@ -350,7 +388,8 @@
bind:this={pixi_target}
class="stage-wrap"
class:bg={!bg}
style:transform="translate({$position_spring.x}px, {$position_spring.y}px)"
style:transform="translate({$position_spring.x}px, {$position_spring.y}px)
scale({$zoom_pan_state.scale}) translate({$zoom_pan_state.translate_x}px, {$zoom_pan_state.translate_y}px)"
></div>
</div>
<div class="tools-wrap">
Expand All @@ -368,6 +407,8 @@
($editor_box.child_left - $editor_box.parent_left) -
0.5}px"
></div>

<ZoomControls {zoom_in} {zoom_out} {reset_zoom_pan} />
</div>

<style>
Expand Down Expand Up @@ -395,6 +436,7 @@
margin-bottom: var(--size-1);
border-radius: var(--radius-md);
overflow: hidden;
transition: transform 0.1s ease-out;
}

.tools-wrap {
Expand All @@ -405,6 +447,8 @@
border: 1px solid var(--block-border-color);
border-radius: var(--radius-sm);
margin: var(--spacing-xxl) 0 var(--spacing-xxl) 0;
background: var(--block-background-fill);
z-index: var(--layer-2);
}

.image-container {
Expand Down
12 changes: 6 additions & 6 deletions js/imageeditor/shared/tools/Brush.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@
mode
);

const { x, y } = event.getLocalPosition($pixi.layer_container);

draw.start({
x: event.screen.x,
y: event.screen.y,
x,
y,
color: selected_color || undefined,
size: selected_size,
opacity: 1
Expand All @@ -127,10 +129,8 @@
return;
}
if (drawing) {
draw.continue({
x: event.screen.x,
y: event.screen.y
});
const { x, y } = event.getLocalPosition($pixi?.layer_container);
draw.continue({ x, y });
}

const x_bound = $crop[0] * $dimensions[0];
Expand Down
47 changes: 47 additions & 0 deletions js/imageeditor/shared/tools/ZoomControls.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script lang="ts">
import { IconButton } from "@gradio/atoms";
import { ZoomIn, ZoomOut, Redo } from "@gradio/icons";

export let zoom_in = (): void => {};
export let zoom_out = (): void => {};
export let reset_zoom_pan = (): void => {};
</script>

<div class="zoom-controls">
<button on:click={zoom_in}><IconButton Icon={ZoomIn} size="medium" /></button>
<button on:click={zoom_out}
><IconButton Icon={ZoomOut} size="medium" /></button
>
<button on:click={reset_zoom_pan}
><IconButton Icon={Redo} size="medium" /></button
>
</div>

<style>
.zoom-controls {
position: absolute;
bottom: 0;
right: 0;
display: flex;
align-items: center;
flex-direction: column;
border: var(--block-border-color) var(--spacing-xxs) solid;
border-radius: var(--radius-md);
margin: var(--spacing-md);
background-color: var(--block-background-fill);
margin: var(--spacing-md);
}

.zoom-controls button {
border: none;
padding: var(--spacing-xxs);
outline: none;
}

.zoom-controls :global(.padded) {
border: none;
outline: none;
box-shadow: none;
padding: var(--spacing-xxs);
}
</style>
1 change: 0 additions & 1 deletion js/imageeditor/shared/utils/pixi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
} from "pixi.js";

import { type LayerScene } from "../layers/utils";
import { background } from "@storybook/theming";

/**
* interface holding references to pixi app components
Expand Down
101 changes: 101 additions & 0 deletions js/imageeditor/shared/utils/zoom_pan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { writable, type Writable } from "svelte/store";

export interface ZoomPanState {
scale: number;
translate_x: number;
translate_y: number;
}

export function init_zoom_pan(initial_scale = 1): {
zoom_pan_state: Writable<ZoomPanState>;
zoom_in: () => void;
zoom_out: () => void;
reset_zoom_pan: () => void;
handle_wheel: (event: WheelEvent) => void;
zoom_to_point: (scale: number, pointX: number, pointY: number) => void;
} {
const zoom_pan_state: Writable<ZoomPanState> = writable({
scale: initial_scale,
translate_x: 0,
translate_y: 0
});

const MIN_SCALE = 0.1;
const MAX_SCALE = 10;
const ZOOM_SPEED = 0.001;

function zoom_to_point(scale: number, pointX: number, pointY: number): void {
zoom_pan_state.update((s) => {
const new_scale = Math.max(MIN_SCALE, Math.min(scale, MAX_SCALE));
const scale_change = new_scale / s.scale;

return {
scale: new_scale,
translate_x: pointX - (pointX - s.translate_x) * scale_change,
translate_y: pointY - (pointY - s.translate_y) * scale_change
};
});
}

function zoom_in(): void {
zoom_pan_state.update((s) => ({
...s,
scale: Math.min(s.scale * 1.5, MAX_SCALE) // More significant zoom in
}));
}

function zoom_out(): void {
zoom_pan_state.update((s) => ({
...s,
scale: Math.max(s.scale / 1.5, MIN_SCALE) // More significant zoom out
}));
}

function reset_zoom_pan(): void {
zoom_pan_state.set({
scale: initial_scale,
translate_x: 0,
translate_y: 0
});
}

function handle_wheel(event: WheelEvent): void {
event.preventDefault();

if (event.ctrlKey || event.metaKey) {
const delta = -event.deltaY;
const zoom_factor = Math.exp(delta * ZOOM_SPEED);

const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const point_x = event.clientX - rect.left;
const point_y = event.clientY - rect.top;

zoom_pan_state.update((s) => {
const new_scale = Math.max(
MIN_SCALE,
Math.min(s.scale * zoom_factor, MAX_SCALE)
);
return {
scale: new_scale,
translate_x: point_x - (point_x - s.translate_x) * zoom_factor,
translate_y: point_y - (point_y - s.translate_y) * zoom_factor
};
});
} else {
zoom_pan_state.update((s) => ({
...s,
translate_x: s.translate_x - event.deltaX,
translate_y: s.translate_y - event.deltaY
}));
}
}

return {
zoom_pan_state,
zoom_in,
zoom_out,
reset_zoom_pan,
handle_wheel,
zoom_to_point
};
}
Loading