|
<script lang="ts" context="module">
|
|
import type { Writable, Readable } from "svelte/store";
|
|
import type { Spring } from "svelte/motion";
|
|
import { type PixiApp } from "./utils/pixi";
|
|
import { type CommandManager } from "./utils/commands";
|
|
|
|
export const EDITOR_KEY = Symbol("editor");
|
|
export type context_type = "bg" | "layers" | "crop" | "draw" | "erase";
|
|
type PartialRecord<K extends keyof any, T> = Partial<Record<K, T>>;
|
|
import { type tool } from "./tools";
|
|
|
|
export interface EditorContext {
|
|
pixi: Writable<PixiApp | null>;
|
|
current_layer: Writable<LayerScene | null>;
|
|
dimensions: Writable<[number, number]>;
|
|
editor_box: Writable<{
|
|
parent_width: number;
|
|
parent_height: number;
|
|
parent_left: number;
|
|
parent_top: number;
|
|
parent_right: number;
|
|
parent_bottom: number;
|
|
child_width: number;
|
|
child_height: number;
|
|
child_left: number;
|
|
child_top: number;
|
|
child_right: number;
|
|
child_bottom: number;
|
|
}>;
|
|
active_tool: Writable<tool>;
|
|
toolbar_box: Writable<DOMRect | Record<string, never>>;
|
|
crop: Writable<[number, number, number, number]>;
|
|
position_spring: Spring<{
|
|
x: number;
|
|
y: number;
|
|
}>;
|
|
command_manager: CommandManager;
|
|
current_history: CommandManager["current_history"];
|
|
register_context: (
|
|
type: context_type,
|
|
{
|
|
reset_fn,
|
|
init_fn
|
|
}: {
|
|
reset_fn?: () => void;
|
|
init_fn?: (dimensions?: [number, number]) => void;
|
|
}
|
|
) => void;
|
|
reset: (clear_image: boolean, dimensions: [number, number]) => void;
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { onMount, setContext, createEventDispatcher, tick } from "svelte";
|
|
import { writable } from "svelte/store";
|
|
import { spring } from "svelte/motion";
|
|
import { Rectangle } from "pixi.js";
|
|
|
|
import { command_manager } from "./utils/commands";
|
|
|
|
import { type LayerScene } from "./layers/utils";
|
|
import { create_pixi_app, type ImageBlobs } from "./utils/pixi";
|
|
import Controls from "./Controls.svelte";
|
|
export let antialias = true;
|
|
export let crop_size: [number, number] | undefined;
|
|
export let changeable = false;
|
|
export let history: boolean;
|
|
export let bg = false;
|
|
export let sources: ("clipboard" | "webcam" | "upload")[];
|
|
const dispatch = createEventDispatcher<{
|
|
clear?: never;
|
|
save: void;
|
|
change: void;
|
|
}>();
|
|
export let crop_constraint = false;
|
|
export let canvas_size: [number, number] | undefined;
|
|
export let parent_height: number;
|
|
|
|
$: orig_canvas_size = canvas_size;
|
|
|
|
const BASE_DIMENSIONS: [number, number] = canvas_size || [800, 600];
|
|
|
|
let dimensions = writable(BASE_DIMENSIONS);
|
|
export let height = 0;
|
|
|
|
let editor_box: EditorContext["editor_box"] = writable({
|
|
parent_width: 0,
|
|
parent_height: parent_height,
|
|
parent_top: 0,
|
|
parent_left: 0,
|
|
parent_right: 0,
|
|
parent_bottom: 0,
|
|
child_width: 0,
|
|
child_height: parent_height,
|
|
child_top: 0,
|
|
child_left: 0,
|
|
child_right: 0,
|
|
child_bottom: 0
|
|
});
|
|
|
|
$: height = $editor_box.child_height;
|
|
|
|
const crop = writable<[number, number, number, number]>([0, 0, 1, 1]);
|
|
const position_spring = spring(
|
|
{ x: 0, y: 0 },
|
|
{
|
|
stiffness: 0.1,
|
|
damping: 0.5
|
|
}
|
|
);
|
|
const pixi = writable<PixiApp | null>(null);
|
|
|
|
const CommandManager = command_manager();
|
|
|
|
const { can_redo, can_undo, current_history } = CommandManager;
|
|
|
|
$: $current_history.previous, dispatch("change");
|
|
|
|
$: {
|
|
history = !!$current_history.previous || $active_tool !== "bg";
|
|
}
|
|
const is_browser = typeof window !== "undefined";
|
|
|
|
const active_tool: Writable<tool> = writable("bg");
|
|
const reset_context: Writable<PartialRecord<context_type, () => void>> =
|
|
writable({});
|
|
const init_context: Writable<
|
|
PartialRecord<context_type, (dimensions?: typeof $dimensions) => void>
|
|
> = writable({});
|
|
const contexts: Writable<context_type[]> = writable([]);
|
|
const toolbar_box: Writable<DOMRect | Record<string, never>> = writable(
|
|
is_browser ? new DOMRect() : {}
|
|
);
|
|
|
|
const sort_order = ["bg", "layers", "crop", "draw", "erase"] as const;
|
|
const editor_context = setContext<EditorContext>(EDITOR_KEY, {
|
|
pixi,
|
|
current_layer: writable(null),
|
|
dimensions,
|
|
editor_box,
|
|
toolbar_box,
|
|
active_tool,
|
|
crop,
|
|
position_spring,
|
|
command_manager: CommandManager,
|
|
current_history,
|
|
register_context: (
|
|
type: context_type,
|
|
{
|
|
reset_fn,
|
|
init_fn
|
|
}: {
|
|
reset_fn?: () => void;
|
|
init_fn?: (dimensions?: [number, number]) => void;
|
|
}
|
|
) => {
|
|
contexts.update((c) => [...c, type]);
|
|
init_context.update((c) => ({ ...c, [type]: init_fn }));
|
|
reset_context.update((c) => ({ ...c, [type]: reset_fn }));
|
|
},
|
|
reset: (clear_image: boolean, dimensions: [number, number]) => {
|
|
bg = false;
|
|
|
|
const _sorted_contexts = $contexts.sort((a, b) => {
|
|
return sort_order.indexOf(a) - sort_order.indexOf(b);
|
|
});
|
|
for (const k of _sorted_contexts) {
|
|
if (k in $reset_context && typeof $reset_context[k] === "function") {
|
|
$reset_context[k]?.();
|
|
}
|
|
}
|
|
|
|
for (const k of _sorted_contexts) {
|
|
if (k in $init_context && typeof $init_context[k] === "function") {
|
|
if (k === "bg" && !clear_image) {
|
|
continue;
|
|
} else {
|
|
$init_context[k]?.(dimensions);
|
|
}
|
|
}
|
|
CommandManager.reset();
|
|
$pixi?.resize?.(...dimensions);
|
|
}
|
|
}
|
|
});
|
|
|
|
let pixi_target: HTMLDivElement;
|
|
|
|
let canvas_wrap: HTMLDivElement;
|
|
|
|
function get_dimensions(parent: HTMLDivElement, child: HTMLDivElement): void {
|
|
if (!parent || !child) return;
|
|
const {
|
|
width: parent_width,
|
|
height: parent_height,
|
|
top: parent_top,
|
|
left: parent_left,
|
|
right: parent_right,
|
|
bottom: parent_bottom
|
|
} = canvas_wrap.getBoundingClientRect();
|
|
const {
|
|
width: child_width,
|
|
height: child_height,
|
|
top: child_top,
|
|
left: child_left,
|
|
right: child_right,
|
|
bottom: child_bottom
|
|
} = child.getBoundingClientRect();
|
|
editor_box.set({
|
|
child_width,
|
|
child_height,
|
|
child_left,
|
|
child_right,
|
|
child_top,
|
|
child_bottom,
|
|
|
|
parent_width,
|
|
parent_height,
|
|
parent_left,
|
|
parent_right,
|
|
parent_top,
|
|
parent_bottom
|
|
});
|
|
}
|
|
|
|
$: if (crop_constraint && bg && !history) {
|
|
set_crop();
|
|
}
|
|
|
|
function set_crop(): void {
|
|
requestAnimationFrame(() => {
|
|
tick().then((v) => ($active_tool = "crop"));
|
|
});
|
|
}
|
|
|
|
function reposition_canvas(): void {
|
|
if (!$editor_box) return;
|
|
const [l, t, w, h] = $crop;
|
|
|
|
const cx = l * $editor_box.child_width;
|
|
const cy = t * $editor_box.child_height;
|
|
const cw = w * $editor_box.child_width;
|
|
const ch = h * $editor_box.child_height;
|
|
|
|
const x = 0.5 * $editor_box.child_width - cx - cw / 2;
|
|
const y = 0.5 * $editor_box.child_height - cy - ch / 2;
|
|
|
|
position_spring.set({ x, y });
|
|
}
|
|
|
|
export async function get_blobs(): Promise<ImageBlobs> {
|
|
if (!$pixi || !$pixi.get_layers)
|
|
return { background: null, layers: [], composite: null };
|
|
const [l, t, w, h] = $crop;
|
|
|
|
return $pixi?.get_blobs(
|
|
$pixi.get_layers(),
|
|
new Rectangle(
|
|
Math.round(l * $dimensions[0]),
|
|
Math.round(t * $dimensions[1]),
|
|
Math.round(w * $dimensions[0]),
|
|
Math.round(h * $dimensions[1])
|
|
),
|
|
$dimensions
|
|
);
|
|
}
|
|
|
|
$: $crop && reposition_canvas();
|
|
$: $position_spring && get_dimensions(canvas_wrap, pixi_target);
|
|
|
|
export function handle_remove(): void {
|
|
editor_context.reset(
|
|
true,
|
|
orig_canvas_size ? orig_canvas_size : $dimensions
|
|
);
|
|
if (!sources.length) {
|
|
set_tool("draw");
|
|
} else {
|
|
set_tool("bg");
|
|
}
|
|
dispatch("clear");
|
|
}
|
|
|
|
onMount(() => {
|
|
const _size = (canvas_size ? canvas_size : crop_size) || [800, 600];
|
|
const app = create_pixi_app({
|
|
target: pixi_target,
|
|
dimensions: _size,
|
|
antialias
|
|
});
|
|
|
|
function resize(width: number, height: number): void {
|
|
app.resize(width, height);
|
|
dimensions.set([width, height]);
|
|
}
|
|
|
|
pixi.set({ ...app, resize });
|
|
|
|
const resizer = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
get_dimensions(canvas_wrap, pixi_target);
|
|
}
|
|
});
|
|
|
|
resizer.observe(canvas_wrap);
|
|
resizer.observe(pixi_target);
|
|
|
|
for (const k of $contexts) {
|
|
if (k in $init_context && typeof $init_context[k] === "function") {
|
|
$init_context[k]?.($dimensions);
|
|
}
|
|
}
|
|
|
|
resize(...$dimensions);
|
|
|
|
return () => {
|
|
$pixi?.destroy();
|
|
resizer.disconnect();
|
|
for (const k of $contexts) {
|
|
if (k in $reset_context) {
|
|
$reset_context[k]?.();
|
|
}
|
|
}
|
|
};
|
|
});
|
|
|
|
let saved_history: null | typeof $current_history = $current_history;
|
|
|
|
function handle_save(): void {
|
|
saved_history = $current_history;
|
|
dispatch("save");
|
|
}
|
|
|
|
export function set_tool(tool: tool): void {
|
|
$active_tool = tool;
|
|
}
|
|
</script>
|
|
|
|
<svelte:window on:scroll={() => get_dimensions(canvas_wrap, pixi_target)} />
|
|
|
|
<div data-testid="image" class="image-container">
|
|
<Controls
|
|
can_undo={$can_undo}
|
|
can_redo={$can_redo}
|
|
can_save={saved_history !== $current_history}
|
|
{changeable}
|
|
on:undo={CommandManager.undo}
|
|
on:redo={CommandManager.redo}
|
|
on:remove_image={handle_remove}
|
|
on:save={handle_save}
|
|
/>
|
|
<div class="wrap" bind:this={canvas_wrap}>
|
|
<div
|
|
bind:this={pixi_target}
|
|
class="stage-wrap"
|
|
class:bg={!bg}
|
|
style:transform="translate({$position_spring.x}px, {$position_spring.y}px)"
|
|
></div>
|
|
</div>
|
|
<div class="tools-wrap">
|
|
<slot />
|
|
</div>
|
|
<div
|
|
class="canvas"
|
|
class:no-border={!bg && $active_tool === "bg" && !history}
|
|
style:width="{$crop[2] * $editor_box.child_width + 1}px"
|
|
style:height="{$crop[3] * $editor_box.child_height + 1}px"
|
|
style:top="{$crop[1] * $editor_box.child_height +
|
|
($editor_box.child_top - $editor_box.parent_top) -
|
|
0.5}px"
|
|
style:left="{$crop[0] * $editor_box.child_width +
|
|
($editor_box.child_left - $editor_box.parent_left) -
|
|
0.5}px"
|
|
></div>
|
|
</div>
|
|
|
|
<style>
|
|
.wrap {
|
|
display: flex;
|
|
width: 100%;
|
|
height: 100%;
|
|
position: relative;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
}
|
|
.canvas {
|
|
position: absolute;
|
|
border: var(--block-border-color) 1px solid;
|
|
pointer-events: none;
|
|
border-radius: var(--radius-md);
|
|
}
|
|
|
|
.no-border {
|
|
border: none;
|
|
}
|
|
|
|
.stage-wrap {
|
|
margin: var(--size-8);
|
|
margin-bottom: var(--size-1);
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tools-wrap {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 0 var(--spacing-xl) 0 0;
|
|
border: 1px solid var(--block-border-color);
|
|
border-radius: var(--radius-sm);
|
|
margin: var(--spacing-xxl) 0 var(--spacing-xxl) 0;
|
|
}
|
|
|
|
.image-container {
|
|
display: flex;
|
|
height: 100%;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
max-height: 100%;
|
|
}
|
|
</style>
|
|
|