| <script lang="ts" context="module"> | |
| import { type ColorInput } from "tinycolor2"; | |
| export interface Eraser { | |
| /** | |
| * The default size of the eraser. | |
| */ | |
| default_size: number | "auto"; | |
| } | |
| export interface Brush extends Eraser { | |
| /** | |
| * The default color of the brush. | |
| */ | |
| default_color: ColorInput; | |
| /** | |
| * The colors to show in the color swatch | |
| */ | |
| colors: ColorInput[]; | |
| /** | |
| * Whether to show _only_ the color swatches specified in `colors`, or to show the color swatches specified in `colors` along with the colorpicker. | |
| */ | |
| color_mode: "fixed" | "defaults"; | |
| } | |
| type brush_option_type = boolean; | |
| </script> | |
| <script lang="ts"> | |
| import tinycolor from "tinycolor2"; | |
| import { clamp } from "../utils/pixi"; | |
| import { getContext, onMount, tick } from "svelte"; | |
| import { type ToolContext, TOOL_KEY } from "./Tools.svelte"; | |
| import { type EditorContext, EDITOR_KEY } from "../ImageEditor.svelte"; | |
| import { draw_path, type DrawCommand } from "./brush"; | |
| import BrushOptions from "./BrushOptions.svelte"; | |
| import type { FederatedPointerEvent } from "pixi.js"; | |
| export let default_size: Brush["default_size"]; | |
| export let default_color: Brush["default_color"] | undefined = undefined; | |
| export let colors: Brush["colors"] | undefined = undefined; | |
| export let color_mode: Brush["color_mode"] | undefined = undefined; | |
| export let mode: "erase" | "draw"; | |
| $: processed_colors = colors | |
| ? colors.map(process_color).filter((_, i) => i < 4) | |
| : []; | |
| $: selected_color = | |
| default_color === "auto" | |
| ? processed_colors[0] | |
| : !default_color | |
| ? "black" | |
| : process_color(default_color); | |
| let brush_options: brush_option_type = false; | |
| const { | |
| pixi, | |
| dimensions, | |
| current_layer, | |
| command_manager, | |
| register_context, | |
| editor_box, | |
| crop, | |
| toolbar_box | |
| } = getContext<EditorContext>(EDITOR_KEY); | |
| const { active_tool, register_tool, current_color } = | |
| getContext<ToolContext>(TOOL_KEY); | |
| let drawing = false; | |
| let draw: DrawCommand; | |
| function generate_sizes(x: number, y: number): number { | |
| const min = clamp(Math.min(x, y), 500, 1000); | |
| return Math.round((min * 2) / 100); | |
| } | |
| $: mode === "draw" && current_color.set(selected_color); | |
| let selected_size = | |
| default_size === "auto" ? generate_sizes(...$dimensions) : default_size; | |
| function pointer_down_handler(event: FederatedPointerEvent): void { | |
| if ($active_tool !== mode) { | |
| return; | |
| } | |
| drawing = true; | |
| if (!$pixi || !$current_layer) { | |
| return; | |
| } | |
| draw = draw_path( | |
| $pixi.renderer!, | |
| $pixi.layer_container, | |
| $current_layer, | |
| mode | |
| ); | |
| draw.start({ | |
| x: event.screen.x, | |
| y: event.screen.y, | |
| color: selected_color || undefined, | |
| size: selected_size, | |
| opacity: 1 | |
| }); | |
| } | |
| function pointer_up_handler(event: FederatedPointerEvent): void { | |
| if (!$pixi || !$current_layer) { | |
| return; | |
| } | |
| if ($active_tool !== mode) { | |
| return; | |
| } | |
| draw.stop(); | |
| command_manager.execute(draw); | |
| drawing = false; | |
| } | |
| function pointer_move_handler(event: FederatedPointerEvent): void { | |
| if ($active_tool !== mode) { | |
| return; | |
| } | |
| if (drawing) { | |
| draw.continue({ | |
| x: event.screen.x, | |
| y: event.screen.y | |
| }); | |
| } | |
| const x_bound = $crop[0] * $dimensions[0]; | |
| const y_bound = $crop[1] * $dimensions[1]; | |
| if ( | |
| x_bound > event.screen.x || | |
| y_bound > event.screen.y || | |
| event.screen.x > x_bound + $crop[2] * $dimensions[0] || | |
| event.screen.y > y_bound + $crop[3] * $dimensions[1] | |
| ) { | |
| brush_cursor = false; | |
| document.body.style.cursor = "auto"; | |
| } else { | |
| brush_cursor = true; | |
| document.body.style.cursor = "none"; | |
| } | |
| if (brush_cursor) { | |
| pos = { | |
| x: event.clientX - $editor_box.child_left, | |
| y: event.clientY - $editor_box.child_top | |
| }; | |
| } | |
| } | |
| let brush_cursor = false; | |
| async function toggle_listeners(on_off: "on" | "off"): Promise<void> { | |
| $pixi?.layer_container[on_off]("pointerdown", pointer_down_handler); | |
| $pixi?.layer_container[on_off]("pointerup", pointer_up_handler); | |
| $pixi?.layer_container[on_off]("pointermove", pointer_move_handler); | |
| $pixi?.layer_container[on_off]( | |
| "pointerenter", | |
| (event: FederatedPointerEvent) => { | |
| if ($active_tool === mode) { | |
| brush_cursor = true; | |
| document.body.style.cursor = "none"; | |
| } | |
| } | |
| ); | |
| $pixi?.layer_container[on_off]( | |
| "pointerleave", | |
| () => ((brush_cursor = false), (document.body.style.cursor = "auto")) | |
| ); | |
| } | |
| register_context(mode, { | |
| init_fn: () => { | |
| toggle_listeners("on"); | |
| }, | |
| reset_fn: () => { | |
| toggle_listeners("off"); | |
| } | |
| }); | |
| const toggle_options = debounce_toggle(); | |
| const unregister = register_tool(mode, { | |
| cb: toggle_options | |
| }); | |
| onMount(() => { | |
| return () => { | |
| unregister(); | |
| toggle_listeners("off"); | |
| }; | |
| }); | |
| let recent_colors: (string | null)[] = [null, null, null]; | |
| function process_color(color: ColorInput): string { | |
| return tinycolor(color).toRgbString(); | |
| } | |
| let pos = { x: 0, y: 0 }; | |
| $: brush_size = | |
| (selected_size / $dimensions[0]) * $editor_box.child_width * 2; | |
| function debounce_toggle(): (should_close?: boolean) => void { | |
| let timeout: NodeJS.Timeout | null = null; | |
| return function executedFunction(should_close?: boolean) { | |
| const later = (): void => { | |
| if (timeout) { | |
| clearTimeout(timeout); | |
| } | |
| if (should_close !== undefined) { | |
| brush_options = should_close; | |
| return; | |
| } | |
| brush_options = !brush_options; | |
| }; | |
| if (timeout) { | |
| clearTimeout(timeout); | |
| } | |
| timeout = setTimeout(later, 100); | |
| }; | |
| } | |
| </script> | |
| <svelte:window | |
| on:keydown={({ key }) => key === "Escape" && toggle_options(false)} | |
| /> | |
| <span | |
| style:transform="translate({pos.x}px, {pos.y}px)" | |
| style:top="{$editor_box.child_top - | |
| $editor_box.parent_top - | |
| brush_size / 2}px" | |
| style:left="{$editor_box.child_left - | |
| $editor_box.parent_left - | |
| brush_size / 2}px" | |
| style:width="{brush_size}px" | |
| style:height="{brush_size}px" | |
| style:opacity={brush_cursor ? 1 : 0} | |
| /> | |
| {#if brush_options} | |
| <div> | |
| <BrushOptions | |
| show_swatch={mode === "draw"} | |
| on:click_outside={() => toggle_options()} | |
| colors={processed_colors} | |
| bind:selected_color | |
| {color_mode} | |
| bind:recent_colors | |
| bind:selected_size | |
| dimensions={$dimensions} | |
| parent_width={$editor_box.parent_width} | |
| parent_height={$editor_box.parent_height} | |
| parent_left={$editor_box.parent_left} | |
| toolbar_box={$toolbar_box} | |
| /> | |
| </div> | |
| {/if} | |
| <style> | |
| span { | |
| position: absolute; | |
| background: rgba(0, 0, 0, 0.5); | |
| pointer-events: none; | |
| border-radius: 50%; | |
| } | |
| </style> | |