2D: Make PageDraw a self-contained lib (#1244)

Moves PageDraw from artcraft app to a component lib, leaving an adapter
implementation and import behind instead
This commit is contained in:
Tanish Manku
2026-03-31 14:43:21 -07:00
committed by GitHub
parent 0151a2e28d
commit 2f73e861d1
56 changed files with 1887 additions and 1942 deletions
@@ -59,7 +59,7 @@ import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { APP_DESCRIPTORS } from "~/config/appMenu";
import { useSceneStore } from "~/pages/PageDraw/stores/SceneState";
import { useSceneStore } from "@storyteller/ui-pagedraw";
import {
is3DEditorInitialized,
is3DSceneLoaded,
@@ -1,157 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* @import "tw-animate-css"; */
@custom-variant dark (&:is(.dark *));
.pegboard-bg {
inset: 0;
z-index: 0;
background-color: #1b1b1b;
/* background-image: radial-gradient(#bbb 1px, transparent 1px),
radial-gradient(#bbb 1px, transparent 1px); */
background-size: 50px 50px;
background-position:
0 0,
15px 15px;
}
/*
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
} */
/* @layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
} */
.brush-picker .react-colorful {
border: 1px solid rgb(255, 255, 255);
margin-bottom: 16px;
}
.brush-picker .react-colorful__saturation {
border: 1px solid rgb(255, 255, 255);
border-radius: 15px 15px 15px 15px; /* top-rounded square */
margin-bottom: 12px;
}
.brush-picker .react-colorful__hue {
border: 1px solid rgba(255, 255, 255, 0.2); /* bar thickness */
border-radius: 30px 30px 30px 30px; /* rounded bottom only */
margin-bottom: 12px;
}
.brush-picker .react-colorful__alpha {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 15px 15px 15px 15px;
margin-bottom: 12px;
}
File diff suppressed because it is too large Load Diff
@@ -1,118 +0,0 @@
import React from "react";
import { SparklesIcon, Undo2, Smartphone, LayoutPanelTop } from "lucide-react";
import { PromptTextAreaProps, AspectRatio } from "./types";
import ImagePreview from "./ImagePreview";
const PromptTextArea: React.FC<
PromptTextAreaProps & {
aspectRatio: AspectRatio;
onAspectRatioChange: (r: AspectRatio) => void;
onStyleClick: () => void;
onImageStyleClick: () => void;
onRandomizeClick: () => void;
onVaryClick: () => void;
}
> = ({
value,
onChange,
images,
onImageUpdate,
onImageRemove,
aspectRatio,
onAspectRatioChange,
onStyleClick,
onImageStyleClick,
onRandomizeClick,
onVaryClick,
}) => {
/* helper to cycle aspect ratios */
const getNextAspectRatio = (): AspectRatio => {
const ratios: AspectRatio[] = ["1:1", "3:2", "2:3"];
const i = ratios.indexOf(aspectRatio);
return ratios[(i + 1) % ratios.length];
};
/* button styling – matches existing toolbar */
const buttonClass =
"flex items-center gap-1 rounded-lg px-3 py-1 " +
"bg-[#3A3A3A] text-sm text-white hover:bg-[#4A4A4A] " +
"transition-colors";
/* ───────── render ───────── */
return (
<div className="flex w-full overflow-hidden rounded-2xl bg-[#2A2A2A]">
{/* LEFT ▸ prompt + toolbar */}
<div className="flex flex-grow flex-col">
{/* prompt field */}
<div className="flex-grow px-7 pt-5">
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter your prompt here…"
className="
max-h-[175px] min-h-[120px] w-full flex-grow resize-y
border-none bg-transparent text-xl
text-white
placeholder-gray-400 outline-none
"
/>
</div>
{/* bottom toolbar */}
<div className="flex items-center gap-4 px-5 py-3">
{/* Style */}
<button onClick={onStyleClick} className={buttonClass}>
<SparklesIcon size={16} />
<span>Style</span>
</button>
{/* Image Style */}
<button onClick={onImageStyleClick} className={buttonClass}>
<LayoutPanelTop size={16} />
<span>Image&nbsp;Style</span>
</button>
{/* Aspect Ratio */}
<button
onClick={() => onAspectRatioChange(getNextAspectRatio())}
className={buttonClass}
>
<Smartphone
size={16}
className={aspectRatio !== "1:1" ? "rotate-90" : ""}
/>
<span>{aspectRatio}</span>
</button>
{/* Randomize */}
<button onClick={onRandomizeClick} className={buttonClass}>
<div className="flex h-4 w-4 items-center justify-center">🎲</div>
<span>Randomize</span>
</button>
{/* Vary */}
<button onClick={onVaryClick} className={buttonClass}>
<Undo2 size={16} />
<span>Vary</span>
</button>
</div>
</div>
{/* RIGHT ▸ image strip (optional) */}
{images.length > 0 && (
<div className="flex w-24 flex-col gap-2 bg-[#232323] p-2">
{images.map((img) => (
<ImagePreview
key={img.id}
image={img}
onUpdate={onImageUpdate}
onRemove={onImageRemove}
/>
))}
</div>
)}
</div>
);
};
export default PromptTextArea;
@@ -1,122 +0,0 @@
// AIStrengthSlider.tsx
import React, { useEffect, useRef, useState } from 'react';
import { AIStrengthSliderProps } from './types';
const Slider: React.FC<AIStrengthSliderProps> = ({
value,
onChange,
width = '100%',
height = 48,
inset = false,
className = '',
}) => {
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [hover, setHover] = useState(false);
const [dragging, setDragging] = useState(false); // ← new
/* ----- paint helpers ----- */
const clamp = (v: number) => Math.max(0, Math.min(1, v));
const paint = (v: number) => {
fillRef.current!.style.transform = `scaleX(${v})`;
handleRef.current!.style.left = `${v * 100}%`;
};
useEffect(() => paint(value), [value]);
/* ----- pointer logic ----- */
const valAt = (x: number) => {
const { left, width } = trackRef.current!.getBoundingClientRect();
return clamp((x - left) / width);
};
const start = (e: React.PointerEvent) => {
setDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const v = valAt(e.clientX);
paint(v);
onChange(v);
};
const move = (e: PointerEvent) => {
if (!dragging) return;
const v = valAt(e.clientX);
paint(v);
onChange(v); // fine to call every frame now
};
const stop = () => setDragging(false);
useEffect(() => {
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
return () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
};
}, [dragging]);
/* ----- derived sizes ----- */
const trackH = typeof height === 'number' ? `${height}px` : height;
/* ----- render ----- */
const showHandle = hover || dragging;
return (
<div className="relative select-none" style={{ width }}>
<div
ref={trackRef}
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value * 100)}
onPointerDown={start}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className={`
group flex items-center rounded-xl cursor-pointer overflow-hidden
bg-[#1A1A1A] hover:bg-[#2A2A2A] transition-colors relative
${inset ? 'shadow-inner' : ''}
${className}
`}
style={{ height: trackH }}
>
{/* fill */}
<div
ref={fillRef}
className="absolute inset-0 bg-white/15 will-change-transform"
style={{ transformOrigin: 'left' }}
/>
{/* handle — visible only on hover OR drag */}
<div
ref={handleRef}
className={`
absolute translate-x-1/2 bg-white will-change-transform
transition-opacity duration-150
${showHandle ? 'opacity-100' : 'opacity-50'}
`}
style={{ width: '1px', height: '60%' }}
/>
{/* left-aligned, vertically centred label */}
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-sm font-medium text-[#ACACAC]">
AI Strength
</span>
{/* percentage on hover */}
<span className="
absolute right-4 top-1/2 -translate-y-1/2 text-sm text-white
opacity-0 group-hover:opacity-100 transition-opacity
min-w-[40px] text-right
">
{Math.round(value * 100)}
</span>
</div>
</div>
);
};
export default Slider;
@@ -1,2 +0,0 @@
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,59 +0,0 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "../../lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -1,37 +0,0 @@
import React, { createContext, useContext, useState } from "react";
import { JobContextType } from "@storyteller/common";
const JobContext = createContext<JobContextType | undefined>(undefined);
export const JobProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [jobTokens, setJobTokens] = useState<string[]>([]);
const addJobToken = (token: string) => {
setJobTokens((prevTokens) => [...prevTokens, token]);
};
const removeJobToken = (token: string) => {
setJobTokens((prevTokens) => prevTokens.filter((t) => t !== token));
};
const clearJobTokens = () => {
setJobTokens([]);
};
return (
<JobContext.Provider
value={{ jobTokens, addJobToken, removeJobToken, clearJobTokens }}
>
{children}
</JobContext.Provider>
);
};
export const useJobContext = () => {
const context = useContext(JobContext);
if (context === undefined) {
throw new Error("useJobContext must be used within a JobProvider");
}
return context;
};
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -1,30 +0,0 @@
/**
* This decodes Base64 encoded PNG images into an image element.
* @param base64String a standard (not "web safe") base64-encoded string
*/
export const DecodeBase64ToImage = async (base64String: string) : Promise<ImageBitmap> => {
// Create an image element
const img = document.createElement("img");
// Convert base64 to data URL if it doesn't include the prefix
const dataUrl = base64String.startsWith("data:")
? base64String
: `data:image/png;base64,${base64String}`;
// Create a promise to handle the image loading
return new Promise((resolve, reject) => {
img.onload = async () => {
try {
const bitmap = await createImageBitmap(img);
resolve(bitmap);
} catch (error) {
reject(error);
}
};
img.onerror = () => reject(new Error("Failed to load image"));
// Set the source to trigger loading
img.src = dataUrl;
});
}
@@ -1,16 +0,0 @@
export const EncodeImageBitmapToArray = async (imageBitmap: ImageBitmap): Promise<Uint8Array> => {
const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error("Failed to get 2D context");
}
ctx.drawImage(imageBitmap, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/png' });
const arrayBuffer = await blob.arrayBuffer();
return new Uint8Array(arrayBuffer);
}
@@ -1,48 +0,0 @@
export class FileUtilities {
static async downloadBlobZip(blob: Blob) {
console.log("Download Blob");
// Create a temporary URL for the Blob
const url = URL.createObjectURL(blob);
// Create an anchor element to simulate the download
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "Test.zip";
// Append the anchor to the body
document.body.appendChild(anchor);
// Programmatically click the anchor to trigger the download
anchor.click();
// Clean up by removing the anchor and revoking the object URL
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
static async blobToFileJpeg(blob: Blob, index: string) {
try {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
const formattedIndex = String(index).padStart(4, "0");
link.download = `${formattedIndex}.jpg`;
// Trigger the download
link.click();
// Clean up the URL object
URL.revokeObjectURL(link.href);
console.log("Done");
} catch (error) {
console.log(error);
}
}
static async createImageFileFromUrl(
url: string,
fileName: string = "image.jpg",
mimeType: string = "image/jpeg",
): Promise<File> {
const response = await fetch(url);
const blob = await response.blob();
return new File([blob], fileName, { type: mimeType });
}
}
@@ -1,19 +0,0 @@
export const SaveImageBitmapToFile = async (imageBitmap: ImageBitmap): Promise<void> => {
try {
const canvas = document.createElement("canvas");
canvas.width = imageBitmap.width;
canvas.height = imageBitmap.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to get 2D context");
ctx.drawImage(imageBitmap, 0, 0);
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b!), "image/png");
});
await FileUtilities.blobToFileJpeg(blob, "output");
} catch (error) {
console.error("Error saving output:", error);
}
}
@@ -1,18 +0,0 @@
export const kebabCase = (str: string) =>
str
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/[\s_]+/g, "-")
.toLowerCase();
export const getFileName = (file: File) => {
return file.name.substring(0, file.name.lastIndexOf("."));
};
export const getFileExtension = (file: File) => {
return file.name.substring(file.name.lastIndexOf(".") + 1);
};
export const clamp = (num: number, min: number, max: number) => {
return Math.min(Math.max(num, min), max);
}
export const normalize = (num: number, min: number, max: number) => {
return (num - min) / (max - min);
}
@@ -14,12 +14,7 @@ import Konva from "konva"; // Import Konva namespace for types
// https://github.com/SaladTechnologies/comfyui-api
import { ActiveEditTool, useEditStore } from "./stores/EditState";
import SplitPane from "../PageDraw/components/ui/SplitPane";
import { useStageCentering } from "../PageDraw/hooks/useCenteredStage";
import { useGlobalMouseUp } from "../PageDraw/hooks/useGlobalMouseUp";
import { useRightPanelLayoutManagement } from "../PageDraw/hooks/useRightPanelLayoutManagement";
import { LineNode } from "../PageDraw/stores/SceneState";
import { Node } from "../PageDraw/Node";
import { SplitPane, useStageCentering, useGlobalMouseUp, useRightPanelLayoutManagement, type LineNode, Node } from "@storyteller/ui-pagedraw";
import { checkerboard } from "@storyteller/common";
import { loadImageFromUrl } from "~/Helpers/ImageHelpers";
import { Vector2d } from "konva/lib/types";
@@ -10,10 +10,7 @@ import {
} from "@storyteller/tauri-api";
import { PromptsApi } from "@storyteller/api";
import { RefImage, usePromptEditStore } from "@storyteller/ui-promptbox";
import { ContextMenuContainer } from "../PageDraw/components/ui/ContextMenu";
import { useCopyPasteHotkeys } from "../PageDraw/hooks/useCopyPasteHotkeys";
import { useDeleteHotkeys } from "../PageDraw/hooks/useDeleteHotkeys";
import { useUndoRedoHotkeys } from "../PageDraw/hooks/useUndoRedoHotkeys";
import { ContextMenuContainer, useCopyPasteHotkeys, useDeleteHotkeys, useUndoRedoHotkeys } from "@storyteller/ui-pagedraw";
import PromptEditor from "./PromptEditor/PromptEditor";
import { ActiveEditTool, useEditStore } from "./stores/EditState";
import { EditPaintSurface } from "./EditPaintSurface";
@@ -1,9 +1,6 @@
import { create } from "zustand";
import { LineNode, generateId } from "~/pages/PageDraw/stores/SceneState";
import { Node } from "~/pages/PageDraw/Node";
import { BaseSelectorImage } from "../BaseImageSelector";
import { FetchProxy } from "libs/tauri-utils/src/lib/FetchProxy";
import { ImageBundle } from "../HistoryStack";
import { LineNode, generateId, Node, type BaseSelectorImage, type ImageBundle } from "@storyteller/ui-pagedraw";
import { FetchProxy } from "@storyteller/tauri-utils";
export type ActiveEditTool = "select" | "edit" | "expand" | "marker" | "eraser";
export type EditOperation = "add" | "minus";
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { useSceneStore } from "../PageDraw/stores/SceneState";
import { useSceneStore } from "@storyteller/ui-pagedraw";
export type TabId =
| "2D"
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-pagedraw",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -0,0 +1,29 @@
// Public API for @storyteller/ui-pagedraw
// Main component
export { default as PageDraw } from "./lib/PageDraw";
// Store
export { useSceneStore, generateId } from "./lib/stores/SceneState";
export type { SceneState, AspectRatioType, LineNode } from "./lib/stores/SceneState";
// Adapter interface
export type { PageDrawAdapter, PageDrawEditRequest, PageDrawInpaintRequest } from "./lib/adapter";
// Shared types
export type { BaseSelectorImage, ImageBundle, DragState } from "./lib/types";
// Node
export { Node } from "./lib/Node";
// Hooks (used by PageEdit)
export { useCopyPasteHotkeys } from "./lib/hooks/useCopyPasteHotkeys";
export { useDeleteHotkeys } from "./lib/hooks/useDeleteHotkeys";
export { useUndoRedoHotkeys } from "./lib/hooks/useUndoRedoHotkeys";
export { useGlobalMouseUp } from "./lib/hooks/useGlobalMouseUp";
export { useStageCentering } from "./lib/hooks/useCenteredStage";
export { useRightPanelLayoutManagement } from "./lib/hooks/useRightPanelLayoutManagement";
// UI components (used by PageEdit)
export { ContextMenuContainer } from "./lib/components/ui/ContextMenu";
export { default as SplitPane } from "./lib/components/ui/SplitPane";
@@ -0,0 +1,287 @@
import { Button } from "@storyteller/ui-button";
import {
faClockRotateLeft,
faTrashAlt,
faTrashXmark,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { Fragment, useEffect, useRef } from "react";
import { twMerge } from "tailwind-merge";
import { BaseSelectorImage, ImageBundle } from "./types";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
isActionReminderOpen,
showActionReminder,
} from "@storyteller/ui-action-reminder-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export type { ImageBundle };
interface HistoryStackProps {
onClear: () => void;
onImageSelect?: (image: BaseSelectorImage) => void;
onImageRemove?: (image: BaseSelectorImage) => void;
imageBundles: ImageBundle[];
pendingPlaceholders?: { id: string; count: number }[];
selectedImageToken?: string;
blurredBackgroundUrl?: string;
}
export const HistoryStack = ({
onClear,
onImageSelect = () => {},
onImageRemove = () => {},
imageBundles,
pendingPlaceholders = [],
selectedImageToken,
blurredBackgroundUrl,
}: HistoryStackProps) => {
const handleSelectWithPreload = (image: BaseSelectorImage) => {
const preload = new Image();
preload.crossOrigin = "anonymous";
(
preload as HTMLImageElement & { decoding?: "sync" | "async" | "auto" }
).decoding = "async";
preload.src = image.url;
const select = () => onImageSelect(image);
const decodeFn = (
preload as HTMLImageElement & { decode?: () => Promise<void> }
).decode;
if (typeof decodeFn === "function") {
decodeFn.call(preload).then(select).catch(select);
} else {
preload.onload = select;
preload.onerror = select;
}
};
// This is used to force image reloads in different sessions
// and prevent fetching CORS-tainted images from cache
const sessionRandBuster = useRef(Math.random());
const scrollRef = useRef<HTMLDivElement | null>(null);
const prevPendingCountRef = useRef<number>(0);
const handleClear = () => {
onClear();
};
const handleOnImageRemove = (baseImage: BaseSelectorImage) => {
onImageRemove(baseImage);
};
// Scroll to top when new pending placeholders are added (after enqueue)
useEffect(() => {
const current = pendingPlaceholders.length;
if (current > prevPendingCountRef.current) {
setTimeout(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, 0);
}
prevPendingCountRef.current = current;
}, [pendingPlaceholders.length]);
// Scroll to top when a new bundle arrives (newest image just resolved)
const prevBundlesLenRef = useRef<number>(0);
useEffect(() => {
if (imageBundles.length > prevBundlesLenRef.current) {
setTimeout(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, 0);
}
prevBundlesLenRef.current = imageBundles.length;
}, [imageBundles.length]);
const getImageThumbnailSource = (image: BaseSelectorImage) => {
if (!!image.thumbnailUrlTemplate) {
return image.thumbnailUrlTemplate.replace("{WIDTH}", "256");
}
if (image.url.startsWith("data:") || image.url.startsWith("blob:")) {
return image.url;
}
console.warn("Using older image source for history stack image", image);
return `${image.url}?historystack+${sessionRandBuster.current}`;
};
return (
<div className="h-auto w-20 rounded-lg">
<div className="glass rounded-lg p-1.5">
<div className="mb-2 flex w-full items-center justify-center">
<FontAwesomeIcon
icon={faClockRotateLeft}
className="p-1 text-gray-400"
/>
</div>
<div
ref={scrollRef}
className={
"scrollbar-hidden flex max-h-[50vh] flex-col items-center justify-start gap-1 overflow-y-auto"
}
>
{/* Pending placeholders first (newest at top) */}
{(() => {
const reversed = [...pendingPlaceholders].slice().reverse();
return reversed.map((p, idx) => (
<Fragment key={`pending-group-${p.id}`}>
{Array.from({ length: Math.max(1, p.count || 1) }).map(
(_, i) => (
<div
key={`pending-${p.id}-${i}`}
className="relative w-full"
>
<div className="st-loading-tile relative aspect-square w-full overflow-hidden rounded-lg">
{blurredBackgroundUrl && (
<img
src={
blurredBackgroundUrl?.startsWith("data:") ||
blurredBackgroundUrl?.startsWith("blob:")
? blurredBackgroundUrl
: `${blurredBackgroundUrl}?placeholderbg`
}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-80 blur-lg"
crossOrigin="anonymous"
/>
)}
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--st-divider)] border-t-[var(--st-fg)]" />
</div>
{/* SVG running border (single solid line) */}
<svg
className="st-border-svg"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<rect
className="st-border-solid"
x="1"
y="1"
width="98"
height="98"
rx="16"
ry="16"
pathLength="200"
/>
</svg>
</div>
</div>
),
)}
{idx < reversed.length - 1 && (
<hr className="my-1.5 h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-[var(--st-divider)]" />
)}
</Fragment>
));
})()}
{pendingPlaceholders.length > 0 && (
<hr className="my-1.5 h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-[var(--st-divider)]" />
)}
{/* Completed images below placeholders, newest bundles first */}
{[...imageBundles]
.slice()
.reverse()
.map((bundle, index) => (
<Fragment key={index}>
{bundle.images.map((image) => (
<Button
key={image.mediaToken}
className={twMerge(
"group relative aspect-square h-full w-full shrink-0 overflow-hidden rounded-lg border-2 bg-transparent p-0 hover:bg-transparent hover:opacity-80",
selectedImageToken === image.mediaToken &&
"border-primary hover:opacity-100",
)}
onClick={() => handleSelectWithPreload(image)}
>
<img
src={getImageThumbnailSource(image)}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
<div
className="absolute -right-0 -top-0 flex h-5 w-5 items-center justify-center rounded-bl-lg bg-red/50 opacity-0 transition-opacity hover:bg-red/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
showActionReminder({
reminderType: "default",
title: "Remove Image",
primaryActionIcon: faTrashXmark,
primaryActionBtnClassName: "bg-red hover:bg-red/80",
message: (
<>
<p className="text-base-fg text-sm opacity-70">
Are you sure you want to remove this image from the history stack? This
action cannot be undone.
</p>
<p className="text-base-fg text-sm opacity-70">
Images removed here can still be found in the library.
</p>
</>
),
primaryActionText: "Delete",
onPrimaryAction: () => {
handleOnImageRemove(image);
isActionReminderOpen.value = false;
},
});
}}
>
<FontAwesomeIcon
icon={faTrashAlt}
className="text-base-fg h-full w-full text-[13px]"
/>
</div>
</Button>
))}
{index < imageBundles.length - 1 && (
<hr
className="my-1.5 h-0.5 min-h-0.5 w-3/4 rounded-md border-none bg-[var(--st-divider)]"
key={"hr" + index}
/>
)}
</Fragment>
))}
</div>
</div>
<div className="mt-3 flex justify-center">
<div className="glass w-fit rounded-xl border-2 border-red/50 shadow-lg hover:border-red/80">
<div className="relative h-full">
<Tooltip
content="Reset All"
position="left"
closeOnClick={true}
className="ms-1 rounded-md bg-red px-3 py-1"
delay={100}
>
<button
className="text-base-fg flex h-10 w-10 items-center justify-center rounded-lg border-2 border-transparent transition-colors hover:bg-red/50"
onClick={() =>
showActionReminder({
reminderType: "default",
title: "Reset All",
primaryActionIcon: faTrashXmark,
primaryActionBtnClassName: "bg-red hover:bg-red/80",
message: (
<p className="text-base-fg text-sm opacity-70">
Are you sure you want to reset all? This will clear all
your work and cannot be undone.
</p>
),
primaryActionText: "Reset all",
onPrimaryAction: () => {
handleClear();
isActionReminderOpen.value = false;
},
})
}
>
<FontAwesomeIcon icon={faXmark} className="h-5 w-5 text-xl" />
</button>
</Tooltip>
</div>
</div>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -21,14 +21,14 @@ import Konva from "konva";
import { LineNode, SceneState, useSceneStore } from "./stores/SceneState";
import { Node } from "./Node";
import { useStageSnapshot } from "./hooks/useUpdateSnapshot";
import "./App.css";
import "./pagedraw.css";
import SplitPane from "./components/ui/SplitPane";
import { useRightPanelLayoutManagement } from "./hooks/useRightPanelLayoutManagement";
import { useStageCentering } from "./hooks/useCenteredStage";
import { useGlobalMouseUp } from "./hooks/useGlobalMouseUp";
import { loadImageFromUrl } from "~/Helpers/ImageHelpers";
import { loadImageFromUrl } from "./utilities/imageHelpers";
import { checkerboard } from "@storyteller/common";
import { DragState } from "../PageEdit/EditPaintSurface";
import { DragState } from "./types";
export type MiraiProps = {
drawNodes: (Node | LineNode)[];
@@ -1,14 +1,9 @@
import React, { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import ImageStyleSelector from "./ImageStyleSelector";
import { PromptEditorProps, ImageStyle } from "./types";
import { ImageStyle } from "./types";
import { PromptBox2D, PromptBox2DProps } from "@storyteller/ui-promptbox";
import { uploadImage } from "../../../components/reusable/UploadModalMedia/uploadImage";
import { EncodeImageBitmapToBase64 } from "../utilities/EncodeImageBitmapToBase64";
import { JobProvider, useJobContext } from "../JobContext";
// Set this value on when enqueue is pressed nasty global variable.
import { getCanvasRenderBitmap } from "../../../signals/canvasRenderBitmap";
import { JobProvider } from "../JobContext";
const PromptEditor: React.FC<PromptBox2DProps> = ({
uploadImage,
@@ -0,0 +1,60 @@
import type { ReactNode } from "react";
import type { ImageModel } from "@storyteller/model-list";
import type { GenerationProvider } from "@storyteller/api-enums";
import type { UploadMediaFn } from "@storyteller/api";
import type { BaseSelectorImage } from "./types";
export interface PageDrawEditRequest {
model?: ImageModel;
provider?: GenerationProvider;
sceneImageMediaToken?: string;
imageMediaTokens?: string[];
prompt?: string;
disableSystemPrompt?: boolean;
imageCount?: number;
/** "auto" | "wide" | "tall" | "square" */
aspectRatio?: string;
/** "1k" | "2k" | "4k" */
imageResolution?: string;
frontendCaller?: string;
frontendSubscriberId?: string;
}
export interface PageDrawInpaintRequest {
model?: ImageModel;
provider?: GenerationProvider;
imageMediaToken?: string;
maskImageRawBytes?: Uint8Array;
prompt?: string;
imageCount?: number;
frontendCaller?: string;
frontendSubscriberId?: string;
}
export interface PageDrawAdapter {
/** Platform-specific edit-image enqueue (Tauri invoke or REST POST). */
enqueueEditImage(req: PageDrawEditRequest): Promise<{ status: string }>;
/** Platform-specific inpaint enqueue (Tauri invoke or REST POST). */
enqueueInpaint(req: PageDrawInpaintRequest): Promise<{ status: string }>;
/** Platform-specific background removal enqueue. */
enqueueBgRemoval(base64Image: string, nodeId: string): Promise<void>;
/** Platform's image upload function, compatible with PromptBox2D's uploadImage prop. */
uploadImage?: UploadMediaFn;
/** Renders the "no base image selected" state — upload card, gallery picker, etc. */
renderBaseImageSelector(props: {
onImageSelect: (image: BaseSelectorImage) => void;
showLoading: boolean;
}): ReactNode;
/** Optional telemetry hook called just before an enqueue. */
onEnqueueMeta?: (meta: {
prompt: string;
refImageUrls: string[];
modelType: string;
timestamp: number;
}) => void;
}
@@ -20,7 +20,7 @@ import {
faTriangle,
faDroplet,
} from "@fortawesome/pro-regular-svg-icons";
import "../../App.css";
import "../../pagedraw.css";
import { HsvaColorPicker, HsvaColor } from "react-colorful";
import { hsvaToHex } from "@uiw/color-convert";
import SliderWithIndicator from "./SliderWithIndicator";
@@ -1,6 +1,6 @@
import { Vector2d } from "konva/lib/types";
import { useEffect } from "react";
import { DragState } from "~/pages/PageEdit/EditPaintSurface";
import { DragState } from "../types";
// This piece of code ensures that when you are selecting or dragging and go off the stage,
// it releases the controls mouse drag.
export const useGlobalMouseUp = (
@@ -0,0 +1,35 @@
/* PageDraw component styles — imported by PageDraw.tsx and PaintSurface.tsx */
.pegboard-bg {
inset: 0;
z-index: 0;
background-color: #1b1b1b;
background-size: 50px 50px;
background-position:
0 0,
15px 15px;
}
/* react-colorful overrides for the brush color picker in SideToolbar */
.brush-picker .react-colorful {
border: 1px solid rgb(255, 255, 255);
margin-bottom: 16px;
}
.brush-picker .react-colorful__saturation {
border: 1px solid rgb(255, 255, 255);
border-radius: 15px;
margin-bottom: 12px;
}
.brush-picker .react-colorful__hue {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 30px;
margin-bottom: 12px;
}
.brush-picker .react-colorful__alpha {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 15px;
margin-bottom: 12px;
}
@@ -1,8 +1,6 @@
import { create } from "zustand";
import { Node, NodeType } from "../Node";
import { EnqueueImageBgRemoval } from "libs/tauri-api/src/lib/enqueue/EnqueueImageBgRemovalCommand";
import { ImageBundle } from "~/pages/PageEdit/HistoryStack";
import { BaseSelectorImage } from "~/pages/PageEdit/BaseImageSelector";
import { ImageBundle, BaseSelectorImage } from "../types";
import { Model3DParams } from "../utilities/render3DModel";
// LineNode type — ordering in PageDraw is by position in drawNodes array (zIndex ignored there)
@@ -273,16 +271,22 @@ export interface SceneState {
bringForward: (nodeIds: string[]) => void;
sendBackward: (nodeIds: string[]) => void;
// Start background removal (enqueues task with Tauri)
beginRemoveBackground: (nodeIds: string[]) => Promise<void>;
// Start background removal — returns {base64, nodeId} for the caller to enqueue via adapter
beginRemoveBackground: (nodeIds: string[]) => Promise<{ base64: string; nodeId: string } | null>;
// Finish background removal (handoff back from a Tauri-sent event)
// Finish background removal (handoff back from the platform event)
finishRemoveBackground: (
nodeId: string,
mediaToken: string,
imageCdnUrl: string,
) => Promise<void>;
// Pending AI generation placeholders (shown in HistoryStack)
pendingGenerations: { id: string; count: number }[];
addPendingGeneration: (id: string, count: number) => void;
resolvePendingGeneration: (id: string) => void;
clearPendingGenerations: () => void;
toggleLock: (nodeIds: string[]) => void;
aspectRatioType: AspectRatioType;
@@ -339,6 +343,9 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
// History stack state
historyImageBundles: [],
// Pending AI generations
pendingGenerations: [],
aspectRatioType: AspectRatioType.NONE,
// Actions
@@ -876,7 +883,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
},
RESET: () => {
set(store.getInitialState());
set({ ...store.getInitialState(), pendingGenerations: [] });
},
// Clipboard actions
@@ -1268,17 +1275,14 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
) as Node | undefined;
if (!firstNode || !firstNode.imageFile) {
return;
return null;
}
try {
const base64Image = await convertFileToBase64(firstNode.imageFile);
const _response = await EnqueueImageBgRemoval({
base64_image: base64Image,
frontend_caller: "canvas",
frontend_subscriber_id: firstNode.id,
});
return { base64: base64Image, nodeId: firstNode.id };
} catch (error) {
console.error("Error starting background removal:", error);
console.error("Error preparing background removal:", error);
return null;
}
},
@@ -1478,4 +1482,20 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
return { historyImageBundles: updatedBundles };
});
},
addPendingGeneration: (id: string, count: number) => {
set((state) => ({
pendingGenerations: [...state.pendingGenerations, { id, count }],
}));
},
resolvePendingGeneration: (id: string) => {
set((state) => ({
pendingGenerations: state.pendingGenerations.filter((p) => p.id !== id),
}));
},
clearPendingGenerations: () => {
set({ pendingGenerations: [] });
},
}));
@@ -0,0 +1,17 @@
import { Vector2d } from "konva/lib/types";
export interface BaseSelectorImage {
url: string;
mediaToken: string;
thumbnailUrlTemplate?: string;
fullImageUrl?: string;
}
export interface ImageBundle {
images: BaseSelectorImage[];
}
export interface DragState extends Vector2d {
anchorX: number;
anchorY: number;
}
@@ -0,0 +1,27 @@
export const normalizeCanvas = (canvas: HTMLCanvasElement, width: number, height: number): OffscreenCanvas => {
const newCanvas = new OffscreenCanvas(width, height);
newCanvas.width = width;
newCanvas.height = height;
const ctx = newCanvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get offscreen canvas context");
}
ctx.imageSmoothingEnabled = true;
ctx.drawImage(canvas, 0, 0, width, height);
return newCanvas;
};
export const drawAlphaMask = (canvas: OffscreenCanvas, width: number, height: number) => {
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
ctx.save();
ctx.globalCompositeOperation = "source-in";
ctx.fillStyle = "black";
ctx.fillRect(0, 0, width, height);
ctx.restore();
};
@@ -0,0 +1,19 @@
export async function loadImage(img: HTMLImageElement) {
return new Promise<HTMLImageElement>((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = reject;
});
}
export async function loadImageFromUrl(url: string) {
const img = new Image();
img.src = url;
return loadImage(img);
}
export async function loadImageFromAnonymousOriginUrl(url: string) {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = url;
return loadImage(img);
}
@@ -0,0 +1,13 @@
{
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,41 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"lib": ["dom", "esnext"],
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
@@ -0,0 +1,34 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,40 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { isExternal } from '../../shared-vite-config';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/pagedraw',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
entry: 'src/index.ts',
name: '@storyteller/ui-pagedraw',
fileName: 'index',
formats: ['es' as const]
},
rollupOptions: {
external: isExternal
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
+8
View File
@@ -592,6 +592,10 @@
"name": "@storyteller/ui-model-selector",
"version": "0.0.1"
},
"libs/components/pagedraw": {
"name": "@storyteller/ui-pagedraw",
"version": "0.0.1"
},
"libs/components/pagination": {
"name": "@storyteller/ui-pagination",
"version": "0.0.1"
@@ -10663,6 +10667,10 @@
"resolved": "libs/components/model-selector",
"link": true
},
"node_modules/@storyteller/ui-pagedraw": {
"resolved": "libs/components/pagedraw",
"link": true
},
"node_modules/@storyteller/ui-pagination": {
"resolved": "libs/components/pagination",
"link": true