|
<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"); |
|
|
|
let _size = (canvas_size ? canvas_size : crop_size) || [800, 600]; |
|
editor_context.reset(true, _size); |
|
} |
|
|
|
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="container"> |
|
<div class="wrap" bind:this={canvas_wrap}> |
|
<div bind:this={pixi_target} class="stage-wrap" class:bg={!bg}></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> |
|
</div> |
|
|
|
<style> |
|
.wrap { |
|
display: flex; |
|
width: 100%; |
|
height: 100%; |
|
position: relative; |
|
justify-content: center; |
|
} |
|
.canvas { |
|
position: absolute; |
|
border: var(--block-border-color) 1px solid; |
|
pointer-events: none; |
|
border-radius: var(--radius-md); |
|
} |
|
|
|
.container { |
|
position: relative; |
|
margin: var(--spacing-md); |
|
} |
|
|
|
.no-border { |
|
border: none; |
|
} |
|
|
|
.stage-wrap { |
|
margin-bottom: var(--size-1); |
|
border-radius: var(--radius-md); |
|
overflow: hidden; |
|
height: fit-content; |
|
width: auto; |
|
} |
|
|
|
.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; |
|
width: fit-content; |
|
margin: 0 auto; |
|
} |
|
|
|
.image-container { |
|
display: flex; |
|
height: 100%; |
|
flex-direction: column; |
|
justify-content: center; |
|
align-items: center; |
|
max-height: 100%; |
|
} |
|
</style> |
|
|