mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
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:
@@ -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 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
+3
-3
@@ -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)[];
|
||||
+2
-7
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -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
-1
@@ -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;
|
||||
}
|
||||
+34
-14
@@ -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,
|
||||
}
|
||||
},
|
||||
}));
|
||||
Generated
+8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user