rspace-online/lib/folk-drawfast.ts

457 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 360px;
min-height: 420px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #f97316, #eab308);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.tool-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #e2e8f0;
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.15s;
}
.tool-btn:hover {
border-color: #f97316;
}
.tool-btn.active {
border-color: #f97316;
background: #fff7ed;
}
.color-swatch {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #e2e8f0;
cursor: pointer;
transition: transform 0.1s;
}
.color-swatch:hover {
transform: scale(1.2);
}
.color-swatch.active {
border-color: #0f172a;
transform: scale(1.15);
}
.size-slider {
width: 80px;
accent-color: #f97316;
}
.size-label {
font-size: 11px;
color: #64748b;
min-width: 20px;
}
.canvas-area {
flex: 1;
position: relative;
cursor: crosshair;
overflow: hidden;
}
.canvas-area canvas {
width: 100%;
height: 100%;
display: block;
}
.export-btn {
padding: 6px 12px;
background: #f97316;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.export-btn:hover {
opacity: 0.9;
}
`;
const COLORS = ["#0f172a", "#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#ffffff"];
interface Stroke {
points: { x: number; y: number; pressure: number }[];
color: string;
size: number;
tool: string;
}
declare global {
interface HTMLElementTagNameMap {
"folk-drawfast": FolkDrawfast;
}
}
export class FolkDrawfast extends FolkShape {
static override tagName = "folk-drawfast";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#strokes: Stroke[] = [];
#currentStroke: Stroke | null = null;
#canvas: HTMLCanvasElement | null = null;
#ctx: CanvasRenderingContext2D | null = null;
#color = "#0f172a";
#brushSize = 4;
#tool = "pen"; // pen | eraser
#isDrawing = false;
get strokes() {
return this.#strokes;
}
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>✏️</span>
<span>Drawfast</span>
</span>
<div class="header-actions">
<button class="export-png-btn" title="Export PNG">💾</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="toolbar-row">
<button class="tool-btn active" data-tool="pen" title="Pen">✏️</button>
<button class="tool-btn" data-tool="eraser" title="Eraser">🧹</button>
<span style="width:1px;height:20px;background:#e2e8f0;margin:0 2px"></span>
${COLORS.map((c) => `<span class="color-swatch${c === "#0f172a" ? " active" : ""}" data-color="${c}" style="background:${c}"></span>`).join("")}
<span style="width:1px;height:20px;background:#e2e8f0;margin:0 2px"></span>
<input type="range" class="size-slider" min="1" max="24" value="4" />
<span class="size-label">4</span>
<span style="flex:1"></span>
<button class="tool-btn" data-tool="clear" title="Clear all">🗑️</button>
</div>
<div class="canvas-area">
<canvas></canvas>
</div>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#canvas = wrapper.querySelector(".canvas-area canvas") as HTMLCanvasElement;
this.#ctx = this.#canvas.getContext("2d");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const exportBtn = wrapper.querySelector(".export-png-btn") as HTMLButtonElement;
const sizeSlider = wrapper.querySelector(".size-slider") as HTMLInputElement;
const sizeLabel = wrapper.querySelector(".size-label") as HTMLElement;
const canvasArea = wrapper.querySelector(".canvas-area") as HTMLElement;
// Tool buttons
wrapper.querySelectorAll(".tool-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const tool = (btn as HTMLElement).dataset.tool;
if (tool === "clear") {
this.#strokes = [];
this.#redraw();
return;
}
this.#tool = tool || "pen";
wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active"));
if (tool !== "clear") btn.classList.add("active");
});
});
// Color swatches
wrapper.querySelectorAll(".color-swatch").forEach((swatch) => {
swatch.addEventListener("click", (e) => {
e.stopPropagation();
this.#color = (swatch as HTMLElement).dataset.color || "#0f172a";
wrapper.querySelectorAll(".color-swatch").forEach((s) => s.classList.remove("active"));
swatch.classList.add("active");
// Switch to pen when picking a color
this.#tool = "pen";
wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active"));
wrapper.querySelector('[data-tool="pen"]')?.classList.add("active");
});
});
// Size slider
sizeSlider.addEventListener("input", (e) => {
e.stopPropagation();
this.#brushSize = parseInt(sizeSlider.value);
sizeLabel.textContent = sizeSlider.value;
});
sizeSlider.addEventListener("pointerdown", (e) => e.stopPropagation());
// Drawing events
this.#canvas.addEventListener("pointerdown", (e) => {
e.stopPropagation();
e.preventDefault();
this.#isDrawing = true;
this.#canvas!.setPointerCapture(e.pointerId);
const pos = this.#getCanvasPos(e);
this.#currentStroke = {
points: [{ ...pos, pressure: e.pressure || 0.5 }],
color: this.#tool === "eraser" ? "#ffffff" : this.#color,
size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize,
tool: this.#tool,
};
});
this.#canvas.addEventListener("pointermove", (e) => {
if (!this.#isDrawing || !this.#currentStroke) return;
e.stopPropagation();
const pos = this.#getCanvasPos(e);
this.#currentStroke.points.push({ ...pos, pressure: e.pressure || 0.5 });
this.#drawStroke(this.#currentStroke);
});
const endDraw = (e: PointerEvent) => {
if (!this.#isDrawing) return;
this.#isDrawing = false;
if (this.#currentStroke && this.#currentStroke.points.length > 0) {
this.#strokes.push(this.#currentStroke);
this.dispatchEvent(new CustomEvent("stroke-complete", {
detail: { stroke: this.#currentStroke },
}));
}
this.#currentStroke = null;
};
this.#canvas.addEventListener("pointerup", endDraw);
this.#canvas.addEventListener("pointerleave", endDraw);
// Export
exportBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#exportPNG();
});
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Size canvas on next frame
requestAnimationFrame(() => {
this.#resizeCanvas(canvasArea);
this.#redraw();
});
// Watch for resize
const ro = new ResizeObserver(() => {
this.#resizeCanvas(canvasArea);
this.#redraw();
});
ro.observe(canvasArea);
return root;
}
#resizeCanvas(container: HTMLElement) {
if (!this.#canvas) return;
const rect = container.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
this.#canvas.width = rect.width * devicePixelRatio;
this.#canvas.height = rect.height * devicePixelRatio;
this.#canvas.style.width = rect.width + "px";
this.#canvas.style.height = rect.height + "px";
if (this.#ctx) {
this.#ctx.scale(devicePixelRatio, devicePixelRatio);
}
}
}
#getCanvasPos(e: PointerEvent): { x: number; y: number } {
const rect = this.#canvas!.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
#drawStroke(stroke: Stroke) {
if (!this.#ctx || stroke.points.length < 2) return;
const ctx = this.#ctx;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = stroke.color;
// Draw last segment for live drawing
const len = stroke.points.length;
const p0 = stroke.points[len - 2];
const p1 = stroke.points[len - 1];
const pressure = (p0.pressure + p1.pressure) / 2;
ctx.lineWidth = stroke.size * (0.5 + pressure);
if (stroke.tool === "eraser") {
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = stroke.size * 3;
} else {
ctx.globalCompositeOperation = "source-over";
}
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.stroke();
ctx.globalCompositeOperation = "source-over";
}
#redraw() {
if (!this.#ctx || !this.#canvas) return;
const ctx = this.#ctx;
const w = this.#canvas.width / devicePixelRatio;
const h = this.#canvas.height / devicePixelRatio;
ctx.clearRect(0, 0, w, h);
// Fill white background
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, w, h);
for (const stroke of this.#strokes) {
if (stroke.points.length < 2) continue;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = stroke.color;
if (stroke.tool === "eraser") {
ctx.globalCompositeOperation = "destination-out";
} else {
ctx.globalCompositeOperation = "source-over";
}
for (let i = 1; i < stroke.points.length; i++) {
const p0 = stroke.points[i - 1];
const p1 = stroke.points[i];
const pressure = (p0.pressure + p1.pressure) / 2;
ctx.lineWidth = stroke.size * (0.5 + pressure);
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.stroke();
}
}
ctx.globalCompositeOperation = "source-over";
}
#exportPNG() {
if (!this.#canvas) return;
const dataUrl = this.#canvas.toDataURL("image/png");
const link = document.createElement("a");
link.download = `drawfast-${Date.now()}.png`;
link.href = dataUrl;
link.click();
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-drawfast",
strokes: this.#strokes.map((s) => ({
points: s.points,
color: s.color,
size: s.size,
tool: s.tool,
})),
};
}
}