- 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
183 lines
5.2 KiB
JavaScript
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
|
|
}
|
|
}
|