viktorvsk f3d33199f0
Some checks failed
CI / Lint & Test (push) Has been cancelled
Deploy Status Page / Build & Deploy (push) Has been cancelled
Волшебная Книга: Russian book storytelling app
- Book-styled UI with parchment aesthetic and Russian navigation
- Characters model with Konva.js 2D canvas drawing (draggable shapes)
- StoryPages model with character association and page navigation
- Stimulus controllers: canvas (editor) + canvas-preview (read-only)
- Full Russian interface: all labels, buttons, flash messages in Russian
2026-04-25 15:29:15 +00:00

183 lines
5.2 KiB
JavaScript

import { Controller } from "@hotwired/stimulus"
import Konva from "konva"
// Draws 2D book characters using Konva.js
// Shapes are draggable; right-click removes them.
export default class extends Controller {
static targets = ["container", "data"]
static values = { initial: String }
connect() {
this._initStage()
if (this.initialValue) this._load(this.initialValue)
this._bindAutoSave()
}
disconnect() {
if (this.stage) this.stage.destroy()
}
// ── Tools ─────────────────────────────────────────────────
addHead() {
this._add(new Konva.Circle({
x: 190, y: 90, radius: 55,
fill: "#f5d5a0", stroke: "#8b6914", strokeWidth: 2,
draggable: true, name: "shape"
}))
}
addBody() {
this._add(new Konva.Rect({
x: 145, y: 155, width: 90, height: 120,
fill: "#8b4513", stroke: "#5a2d0c", strokeWidth: 2,
cornerRadius: 6, draggable: true, name: "shape"
}))
}
addEye() {
const g = new Konva.Group({ x: 175, y: 80, draggable: true, name: "shape" })
g.add(new Konva.Circle({ x: 0, y: 0, radius: 8, fill: "#fff", stroke: "#333", strokeWidth: 1 }))
g.add(new Konva.Circle({ x: 0, y: 0, radius: 4, fill: "#1a0a00" }))
g.add(new Konva.Circle({ x: 2, y: -2, radius: 1.5, fill: "#fff" }))
this._add(g)
}
addMouth() {
this._add(new Konva.Arc({
x: 190, y: 120, innerRadius: 18, outerRadius: 22,
angle: 180, rotation: 0,
fill: "#c0392b", stroke: "#7b241c", strokeWidth: 1,
draggable: true, name: "shape"
}))
}
addHair() {
this._add(new Konva.Ellipse({
x: 190, y: 42, radiusX: 60, radiusY: 28,
fill: "#4a2c0a", stroke: "#2c1a05", strokeWidth: 1,
draggable: true, name: "shape"
}))
}
addArm() {
this._add(new Konva.Rect({
x: 110, y: 160, width: 28, height: 80,
fill: "#8b4513", stroke: "#5a2d0c", strokeWidth: 1,
cornerRadius: 8, draggable: true, name: "shape", rotation: -8
}))
}
addLeg() {
this._add(new Konva.Rect({
x: 165, y: 275, width: 32, height: 100,
fill: "#2c3e50", stroke: "#1a252f", strokeWidth: 1,
cornerRadius: 6, draggable: true, name: "shape"
}))
}
addAccessory() {
// A simple star / magic wand
this._add(new Konva.Star({
x: 240, y: 80, numPoints: 5,
innerRadius: 12, outerRadius: 24,
fill: "#f1c40f", stroke: "#c9a227", strokeWidth: 1.5,
draggable: true, name: "shape"
}))
}
clear() {
if (!confirm("Очистить холст?")) return
this.layer.destroyChildren()
this._save()
this.layer.draw()
}
// ── Private ────────────────────────────────────────────────
_initStage() {
const w = this.containerTarget.offsetWidth || 380
const h = this.containerTarget.offsetHeight || 480
this.stage = new Konva.Stage({ container: this.containerTarget, width: w, height: h })
this.layer = new Konva.Layer()
this.stage.add(this.layer)
// Subtle parchment grid
const bg = new Konva.Rect({ x: 0, y: 0, width: w, height: h, fill: "#f0e4c8", listening: false })
this.layer.add(bg)
this.layer.draw()
}
_add(shape) {
shape.on("contextmenu", (e) => {
e.evt.preventDefault()
shape.destroy()
this._save()
this.layer.draw()
})
this.layer.add(shape)
this.layer.draw()
this._save()
}
_bindAutoSave() {
this.layer.on("dragend", () => this._save())
}
_save() {
if (this.hasDataTarget) {
this.dataTarget.value = JSON.stringify(this.layer.toJSON())
}
}
_load(json) {
try {
const data = JSON.parse(json)
if (data && data.children) {
data.children.forEach(child => {
let shape
switch (child.className) {
case "Circle": shape = new Konva.Circle(child.attrs); break
case "Rect": shape = new Konva.Rect(child.attrs); break
case "Ellipse": shape = new Konva.Ellipse(child.attrs); break
case "Arc": shape = new Konva.Arc(child.attrs); break
case "Star": shape = new Konva.Star(child.attrs); break
case "Group": shape = this._buildGroup(child); break
default: return
}
if (shape) {
shape.draggable(true)
shape.on("contextmenu", (e) => {
e.evt.preventDefault()
shape.destroy()
this._save()
this.layer.draw()
})
this.layer.add(shape)
}
})
this.layer.draw()
}
} catch (e) {
console.warn("[canvas] failed to load saved data", e)
}
}
_buildGroup(data) {
const g = new Konva.Group(data.attrs)
if (data.children) {
data.children.forEach(child => {
let shape
switch (child.className) {
case "Circle": shape = new Konva.Circle(child.attrs); break
case "Rect": shape = new Konva.Rect(child.attrs); break
default: return
}
if (shape) g.add(shape)
})
}
return g
}
}