nio-cat 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nio.js +32 -1
- package/package.json +1 -1
- package/src/github.js +144 -0
- package/src/pair.js +5 -1
- package/src/server.js +26 -1
- package/web/app/app.js +1318 -0
- package/web/app/index.html +76 -0
- package/web/app/style.css +381 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/board.css +170 -0
- package/web/board.js +1180 -0
- package/web/bot.css +60 -0
- package/web/bot.js +48 -0
- package/web/favicon.svg +1 -0
- package/web/icon-192.png +0 -0
- package/web/icon-512.png +0 -0
- package/web/icon-maskable-512.png +0 -0
- package/web/index.html +140 -0
- package/web/manifest.webmanifest +17 -0
- package/web/og.png +0 -0
- package/web/sw.js +62 -0
package/web/board.js
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
/* Nio · Pissarra: llenç infinit per dibuixar diagrames, amb un xat que els crea. */
|
|
2
|
+
(() => {
|
|
3
|
+
'use strict';
|
|
4
|
+
const KEY = 'nio.board.v1';
|
|
5
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
6
|
+
const GAP = 3;
|
|
7
|
+
const HAND = '"Caveat", "Segoe Print", "Comic Sans MS", cursive';
|
|
8
|
+
const CLEAN = '"Manrope", system-ui, sans-serif';
|
|
9
|
+
const STROKES = [['#272224', 'Negre'], ['#e2382b', 'Vermell'], ['#2f7d4f', 'Verd'], ['#2b5bd8', 'Blau'], ['#e07b1f', 'Taronja'], ['#8a5cf6', 'Lila']];
|
|
10
|
+
const FILLS = [['none', 'Sense fons'], ['#fffefd', 'Blanc'], ['#fde4df', 'Vermell clar'], ['#e1f0e6', 'Verd clar'], ['#e2e9fb', 'Blau clar'], ['#fbf1bf', 'Groc clar']];
|
|
11
|
+
const NAMED = { negre: '#272224', vermell: '#e2382b', roig: '#e2382b', verd: '#2f7d4f', blau: '#2b5bd8', taronja: '#e07b1f', lila: '#8a5cf6', groc: '#c9a400', gris: '#69615c' };
|
|
12
|
+
const NAMED_FILL = { blanc: '#fffefd', vermell: '#fde4df', roig: '#fde4df', verd: '#e1f0e6', blau: '#e2e9fb', groc: '#fbf1bf', taronja: '#fde7cf', lila: '#ece3fd', gris: '#ece9e3' };
|
|
13
|
+
const SIZES = { s: 18, m: 24, l: 34 };
|
|
14
|
+
const TOOLS = [
|
|
15
|
+
['select', 'Selecciona', 'V', '1', '<path d="M5 3l6 17 2.2-6.8L20 11z"/>'],
|
|
16
|
+
['hand', 'Mou el llenç', 'H', '', '<path d="M18 11V6.5a1.5 1.5 0 0 0-3 0V11M15 10V4.5a1.5 1.5 0 0 0-3 0V10M12 10V5.5a1.5 1.5 0 0 0-3 0V13l-1.6-1.7a1.6 1.6 0 0 0-2.4 2.1L9 18a6 6 0 0 0 4.6 2H15a5 5 0 0 0 5-5v-4.5a1.5 1.5 0 0 0-3 0"/>'],
|
|
17
|
+
['rect', 'Rectangle', 'R', '2', '<rect x="4" y="6" width="16" height="12" rx="2"/>'],
|
|
18
|
+
['ellipse', 'El·lipse', 'O', '3', '<ellipse cx="12" cy="12" rx="8.5" ry="6.5"/>'],
|
|
19
|
+
['diamond', 'Rombe', 'D', '4', '<path d="M12 3.5l8.5 8.5-8.5 8.5L3.5 12z"/>'],
|
|
20
|
+
['arrow', 'Fletxa', 'A', '5', '<path d="M5 19L19 5M10 5h9v9"/>'],
|
|
21
|
+
['line', 'Línia', 'L', '6', '<path d="M5 19L19 5"/>'],
|
|
22
|
+
['draw', 'Llapis', 'P', '7', '<path d="M4 20l1.2-4.4L16 4.8a2 2 0 0 1 2.9 0l.3.3a2 2 0 0 1 0 2.9L8.4 18.8z"/>'],
|
|
23
|
+
['text', 'Text', 'T', '8', '<path d="M5 7V5h14v2M12 5v14M9 19h6"/>'],
|
|
24
|
+
['eraser', 'Goma', 'E', '0', '<path d="M8 20h12M6.4 14.6l7.2-7.2a2 2 0 0 1 2.8 0l2.2 2.2a2 2 0 0 1 0 2.8L12 19H9.6z"/>']
|
|
25
|
+
];
|
|
26
|
+
const ICON = {
|
|
27
|
+
undo: '<path d="M9 14L4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/>',
|
|
28
|
+
redo: '<path d="M15 14l5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/>',
|
|
29
|
+
minus: '<path d="M6 12h12"/>', plus: '<path d="M12 6v12M6 12h12"/>',
|
|
30
|
+
fit: '<path d="M4 9V5h4M20 9V5h-4M4 15v4h4M20 15v4h-4"/>',
|
|
31
|
+
chat: '<path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/>',
|
|
32
|
+
menu: '<circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/>',
|
|
33
|
+
lock: '<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
|
|
34
|
+
style: '<circle cx="8" cy="9" r="1.4"/><circle cx="13" cy="7" r="1.4"/><circle cx="17" cy="11" r="1.4"/><path d="M12 21a9 9 0 1 1 9-9c0 2-1.5 3-3.5 3H16a2 2 0 0 0-1.4 3.4A1.6 1.6 0 0 1 12 21z"/>',
|
|
35
|
+
close: '<path d="M6 6l12 12M18 6L6 18"/>', send: '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
|
36
|
+
trash: '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>', copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>',
|
|
37
|
+
up: '<path d="M12 19V5M6 11l6-6 6 6"/>', down: '<path d="M12 5v14M6 13l6 6 6-6"/>'
|
|
38
|
+
};
|
|
39
|
+
const svgI = (p, cls = '') => `<svg class="ico ${cls}" viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
|
|
40
|
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
41
|
+
const uid = () => Math.random().toString(36).slice(2, 10);
|
|
42
|
+
const norm = s => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/·/g, '').toLowerCase().trim();
|
|
43
|
+
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
44
|
+
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
45
|
+
|
|
46
|
+
/* ---------- Estat ---------- */
|
|
47
|
+
function fresh() {
|
|
48
|
+
return { v: 1, els: [], view: { x: 0, y: 0, z: 1 }, opts: { rough: true, font: 'hand', grid: true, chat: true },
|
|
49
|
+
style: { stroke: '#272224', fill: 'none', sw: 2, dash: false, fs: 'm' }, chat: [] };
|
|
50
|
+
}
|
|
51
|
+
function load() {
|
|
52
|
+
try { const s = JSON.parse(localStorage.getItem(KEY)); if (s && s.v === 1 && Array.isArray(s.els)) return Object.assign(fresh(), s); } catch (e) {}
|
|
53
|
+
return fresh();
|
|
54
|
+
}
|
|
55
|
+
let B = load();
|
|
56
|
+
let saveT = 0;
|
|
57
|
+
const save = () => { clearTimeout(saveT); saveT = setTimeout(() => { try { localStorage.setItem(KEY, JSON.stringify(B)); } catch (e) {} }, 250); };
|
|
58
|
+
let hist = [], fut = [];
|
|
59
|
+
function record() { hist.push(JSON.stringify(B.els)); if (hist.length > 120) hist.shift(); fut = []; }
|
|
60
|
+
function undo() { if (!hist.length) return false; fut.push(JSON.stringify(B.els)); B.els = JSON.parse(hist.pop()); sel.clear(); full(); save(); return true; }
|
|
61
|
+
function redo() { if (!fut.length) return false; hist.push(JSON.stringify(B.els)); B.els = JSON.parse(fut.pop()); sel.clear(); full(); save(); return true; }
|
|
62
|
+
|
|
63
|
+
let R = null; // referències DOM quan està muntat
|
|
64
|
+
let sel = new Set();
|
|
65
|
+
let tool = 'select', lock = false, spaceDown = false;
|
|
66
|
+
let clip = null;
|
|
67
|
+
const byId = id => B.els.find(e => e.id === id);
|
|
68
|
+
const isShape = el => el && (el.type === 'rect' || el.type === 'ellipse' || el.type === 'diamond');
|
|
69
|
+
const isLinear = el => el && (el.type === 'arrow' || el.type === 'line');
|
|
70
|
+
const fontOf = () => B.opts.font === 'hand' ? HAND : CLEAN;
|
|
71
|
+
|
|
72
|
+
/* ---------- Mesura de text ---------- */
|
|
73
|
+
const mctx = document.createElement('canvas').getContext('2d');
|
|
74
|
+
function measure(text, fs) { mctx.font = `500 ${fs}px ${fontOf()}`; return mctx.measureText(text).width; }
|
|
75
|
+
function wrap(text, fs, maxW) {
|
|
76
|
+
const out = [];
|
|
77
|
+
String(text || '').split('\n').forEach(par => {
|
|
78
|
+
if (!maxW) { out.push(par); return; }
|
|
79
|
+
let line = '';
|
|
80
|
+
par.split(/\s+/).forEach(w => {
|
|
81
|
+
const t = line ? line + ' ' + w : w;
|
|
82
|
+
if (measure(t, fs) > maxW && line) { out.push(line); line = w; } else line = t;
|
|
83
|
+
});
|
|
84
|
+
out.push(line);
|
|
85
|
+
});
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
const fsOf = el => SIZES[el.fs] || el.fsPx || SIZES.m;
|
|
89
|
+
const textW = el => Math.max(30, el.type === 'rect' ? el.w - 28 : el.type === 'ellipse' ? el.w * .7 - 8 : el.w * .5 - 6);
|
|
90
|
+
function fitText(el) {
|
|
91
|
+
const fs = fsOf(el), lines = String(el.text || '').split('\n');
|
|
92
|
+
el.w = Math.max(20, ...lines.map(l => measure(l, fs))) + 4;
|
|
93
|
+
el.h = lines.length * fs * 1.25;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ---------- Traç fet a mà ---------- */
|
|
97
|
+
function rng(seed) { let s = (seed >>> 0) || 1; return () => { s ^= s << 13; s ^= s >>> 17; s ^= s << 5; return ((s >>> 0) % 100000) / 100000; }; }
|
|
98
|
+
const f1 = n => Math.round(n * 10) / 10;
|
|
99
|
+
function rline(x1, y1, x2, y2, r, amt) {
|
|
100
|
+
const len = Math.hypot(x2 - x1, y2 - y1), o = Math.min(amt, len * .05 + .4), j = () => (r() - .5) * 2 * o;
|
|
101
|
+
const mx = (x1 + x2) / 2 + j() * 1.4, my = (y1 + y2) / 2 + j() * 1.4;
|
|
102
|
+
return `M${f1(x1 + j())} ${f1(y1 + j())}Q${f1(mx)} ${f1(my)} ${f1(x2 + j())} ${f1(y2 + j())}`;
|
|
103
|
+
}
|
|
104
|
+
function rlineX(x1, y1, x2, y2, r, amt) {
|
|
105
|
+
const len = Math.hypot(x2 - x1, y2 - y1), o = Math.min(amt, len * .03 + .3), j = () => (r() - .5) * 2 * o;
|
|
106
|
+
return `M${f1(x1)} ${f1(y1)}Q${f1((x1 + x2) / 2 + j() * 1.4)} ${f1((y1 + y2) / 2 + j() * 1.4)} ${f1(x2)} ${f1(y2)}`;
|
|
107
|
+
}
|
|
108
|
+
function smooth(pts, closed) {
|
|
109
|
+
if (pts.length < 2) return '';
|
|
110
|
+
const p = closed ? [pts[pts.length - 1], ...pts, pts[0], pts[1]] : [pts[0], ...pts, pts[pts.length - 1]];
|
|
111
|
+
let d = `M${f1(p[1][0])} ${f1(p[1][1])}`;
|
|
112
|
+
for (let i = 1; i < p.length - 2; i++) {
|
|
113
|
+
const [x0, y0] = p[i - 1], [x1, y1] = p[i], [x2, y2] = p[i + 1], [x3, y3] = p[i + 2];
|
|
114
|
+
d += `C${f1(x1 + (x2 - x0) / 6)} ${f1(y1 + (y2 - y0) / 6)} ${f1(x2 - (x3 - x1) / 6)} ${f1(y2 - (y3 - y1) / 6)} ${f1(x2)} ${f1(y2)}`;
|
|
115
|
+
}
|
|
116
|
+
return d;
|
|
117
|
+
}
|
|
118
|
+
function polyPath(pts, r, bow) {
|
|
119
|
+
let d = `M${f1(pts[0][0])} ${f1(pts[0][1])}`;
|
|
120
|
+
for (let i = 0; i < pts.length; i++) {
|
|
121
|
+
const p = pts[i], q = pts[(i + 1) % pts.length], o = Math.min(bow, Math.hypot(q[0] - p[0], q[1] - p[1]) * .02 + .3);
|
|
122
|
+
d += `Q${f1((p[0] + q[0]) / 2 + (r() - .5) * 2 * o)} ${f1((p[1] + q[1]) / 2 + (r() - .5) * 2 * o)} ${f1(q[0])} ${f1(q[1])}`;
|
|
123
|
+
}
|
|
124
|
+
return d + 'Z';
|
|
125
|
+
}
|
|
126
|
+
function shapePaths(el) {
|
|
127
|
+
const { x, y, w, h } = el, rough = B.opts.rough, r = rng(el.seed), amt = .8 + el.sw * .3, bow = 1 + el.sw * .3;
|
|
128
|
+
const jit = (pts, k) => pts.map(p => [p[0] + (r() - .5) * 2 * amt * k, p[1] + (r() - .5) * 2 * amt * k]);
|
|
129
|
+
if (el.type === 'rect') {
|
|
130
|
+
const c = Math.min(8, w / 2, h / 2);
|
|
131
|
+
if (!rough) {
|
|
132
|
+
const clean = `M${x + c} ${y}H${x + w - c}Q${x + w} ${y} ${x + w} ${y + c}V${y + h - c}Q${x + w} ${y + h} ${x + w - c} ${y + h}H${x + c}Q${x} ${y + h} ${x} ${y + h - c}V${y + c}Q${x} ${y} ${x + c} ${y}Z`;
|
|
133
|
+
return { fill: clean, stroke: [clean] };
|
|
134
|
+
}
|
|
135
|
+
const pts = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], p0 = polyPath(jit(pts, .5), r, bow);
|
|
136
|
+
return { fill: p0, stroke: [p0, polyPath(jit(pts, 1.4), r, bow)] };
|
|
137
|
+
}
|
|
138
|
+
if (el.type === 'diamond') {
|
|
139
|
+
const pts = [[x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2]];
|
|
140
|
+
const clean = `M${pts.map(p => p.join(' ')).join('L')}Z`;
|
|
141
|
+
if (!rough) return { fill: clean, stroke: [clean] };
|
|
142
|
+
const p0 = polyPath(jit(pts, .5), r, bow);
|
|
143
|
+
return { fill: p0, stroke: [p0, polyPath(jit(pts, 1.4), r, bow)] };
|
|
144
|
+
}
|
|
145
|
+
const cx = x + w / 2, cy = y + h / 2, rx = Math.max(1, w / 2), ry = Math.max(1, h / 2);
|
|
146
|
+
const clean = `M${cx - rx} ${cy}a${rx} ${ry} 0 1 0 ${rx * 2} 0a${rx} ${ry} 0 1 0 ${-rx * 2} 0Z`;
|
|
147
|
+
if (!rough) return { fill: clean, stroke: [clean] };
|
|
148
|
+
const ring = k => {
|
|
149
|
+
const n = 28, pts = [];
|
|
150
|
+
for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2, q = 1 + (r() - .5) * .02 * k; pts.push([cx + Math.cos(a) * rx * q, cy + Math.sin(a) * ry * q]); }
|
|
151
|
+
return smooth(pts, true) + 'Z';
|
|
152
|
+
};
|
|
153
|
+
const p0 = ring(1);
|
|
154
|
+
return { fill: p0, stroke: [p0, ring(2.4)] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ---------- Geometria ---------- */
|
|
158
|
+
const center = el => [el.x + el.w / 2, el.y + el.h / 2];
|
|
159
|
+
function edgePoint(el, toward) {
|
|
160
|
+
const [cx, cy] = center(el); let dx = toward[0] - cx, dy = toward[1] - cy;
|
|
161
|
+
if (!dx && !dy) dy = 1;
|
|
162
|
+
const hw = el.w / 2 + GAP, hh = el.h / 2 + GAP;
|
|
163
|
+
let t;
|
|
164
|
+
if (el.type === 'ellipse') t = 1 / Math.sqrt((dx * dx) / (hw * hw) + (dy * dy) / (hh * hh));
|
|
165
|
+
else if (el.type === 'diamond') t = 1 / (Math.abs(dx) / hw + Math.abs(dy) / hh);
|
|
166
|
+
else t = Math.min(hw / Math.abs(dx || 1e-9), hh / Math.abs(dy || 1e-9));
|
|
167
|
+
return [cx + dx * t, cy + dy * t];
|
|
168
|
+
}
|
|
169
|
+
function ends(a) {
|
|
170
|
+
let p1 = [a.x + a.points[0][0], a.y + a.points[0][1]], p2 = [a.x + a.points[a.points.length - 1][0], a.y + a.points[a.points.length - 1][1]];
|
|
171
|
+
const s = a.start && byId(a.start), e = a.end && byId(a.end);
|
|
172
|
+
const t1 = e ? center(e) : p2, t2 = s ? center(s) : p1;
|
|
173
|
+
if (s) p1 = edgePoint(s, t1);
|
|
174
|
+
if (e) p2 = edgePoint(e, t2);
|
|
175
|
+
return [p1, p2];
|
|
176
|
+
}
|
|
177
|
+
function bbox(el) {
|
|
178
|
+
if (isLinear(el)) { const [a, b] = ends(el); return { x: Math.min(a[0], b[0]), y: Math.min(a[1], b[1]), w: Math.abs(b[0] - a[0]), h: Math.abs(b[1] - a[1]) }; }
|
|
179
|
+
if (el.type === 'draw') {
|
|
180
|
+
const xs = el.points.map(p => p[0] + el.x), ys = el.points.map(p => p[1] + el.y);
|
|
181
|
+
return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
|
|
182
|
+
}
|
|
183
|
+
return { x: el.x, y: el.y, w: el.w, h: el.h };
|
|
184
|
+
}
|
|
185
|
+
function union(list) {
|
|
186
|
+
if (!list.length) return null;
|
|
187
|
+
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
|
|
188
|
+
list.forEach(el => { const b = bbox(el); x1 = Math.min(x1, b.x); y1 = Math.min(y1, b.y); x2 = Math.max(x2, b.x + b.w); y2 = Math.max(y2, b.y + b.h); });
|
|
189
|
+
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ---------- Pintat ---------- */
|
|
193
|
+
function labelSVG(text, cx, cy, fs, color, maxW, bg) {
|
|
194
|
+
if (!text) return '';
|
|
195
|
+
const lines = wrap(text, fs, maxW), lh = fs * 1.25, top = cy - (lines.length * lh) / 2 + lh * .78;
|
|
196
|
+
let out = '';
|
|
197
|
+
if (bg) {
|
|
198
|
+
const w = Math.max(...lines.map(l => measure(l, fs))) + 12, h = lines.length * lh + 4;
|
|
199
|
+
out += `<rect x="${f1(cx - w / 2)}" y="${f1(cy - h / 2)}" width="${f1(w)}" height="${f1(h)}" rx="6" fill="${bg}"/>`;
|
|
200
|
+
}
|
|
201
|
+
out += `<text x="${f1(cx)}" y="${f1(top)}" fill="${color}" font-size="${fs}" font-family='${fontOf()}' font-weight="500" text-anchor="middle">${lines.map((l, i) => `<tspan x="${f1(cx)}" dy="${i ? lh : 0}">${esc(l) || ' '}</tspan>`).join('')}</text>`;
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
function elSVG(el, exp) {
|
|
205
|
+
const dash = el.dash ? ` stroke-dasharray="${el.sw * 4} ${el.sw * 3}"` : '';
|
|
206
|
+
const st = `fill="none" stroke="${el.stroke}" stroke-width="${el.sw}" stroke-linecap="round" stroke-linejoin="round"${dash}`;
|
|
207
|
+
let s = '';
|
|
208
|
+
if (isShape(el)) {
|
|
209
|
+
const p = shapePaths(el);
|
|
210
|
+
if (el.fill && el.fill !== 'none') s += `<path d="${p.fill}" fill="${el.fill}"/>`;
|
|
211
|
+
s += p.stroke.map((d, i) => `<path d="${d}" ${st}${i ? ' opacity=".7"' : ''}/>`).join('');
|
|
212
|
+
if (el.text) s += labelSVG(el.text, el.x + el.w / 2, el.y + el.h / 2, fsOf(el), el.stroke, textW(el));
|
|
213
|
+
if (!exp) s += `<path class="hit" d="${p.fill}"/>`;
|
|
214
|
+
} else if (isLinear(el)) {
|
|
215
|
+
const [a, b] = ends(el), r = rng(el.seed), amt = 1 + el.sw * .4;
|
|
216
|
+
const body = B.opts.rough ? rlineX(a[0], a[1], b[0], b[1], r, amt) + rlineX(a[0], a[1], b[0], b[1], r, amt * .7) : `M${f1(a[0])} ${f1(a[1])}L${f1(b[0])} ${f1(b[1])}`;
|
|
217
|
+
let head = '';
|
|
218
|
+
if (el.type === 'arrow') {
|
|
219
|
+
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]), L = Math.min(16 + el.sw * 2, Math.hypot(b[0] - a[0], b[1] - a[1]) * .5);
|
|
220
|
+
const h1 = [b[0] - L * Math.cos(ang - .45), b[1] - L * Math.sin(ang - .45)], h2 = [b[0] - L * Math.cos(ang + .45), b[1] - L * Math.sin(ang + .45)];
|
|
221
|
+
head = `M${f1(h1[0])} ${f1(h1[1])}L${f1(b[0])} ${f1(b[1])}L${f1(h2[0])} ${f1(h2[1])}`;
|
|
222
|
+
}
|
|
223
|
+
s += `<path d="${body}" ${st}/>`;
|
|
224
|
+
if (head) s += `<path d="${head}" fill="none" stroke="${el.stroke}" stroke-width="${el.sw}" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
225
|
+
if (el.text) s += labelSVG(el.text, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2, fsOf(el) * .8, el.stroke, 220, exp ? '#f0ede7' : 'var(--bd-bg)');
|
|
226
|
+
if (!exp) s += `<path class="hit line" d="M${f1(a[0])} ${f1(a[1])}L${f1(b[0])} ${f1(b[1])}"/>`;
|
|
227
|
+
} else if (el.type === 'draw') {
|
|
228
|
+
const d = smooth(el.points.map(p => [p[0] + el.x, p[1] + el.y]), false) || `M${el.x} ${el.y}h.1`;
|
|
229
|
+
s += `<path d="${d}" fill="none" stroke="${el.stroke}" stroke-width="${el.sw * 1.4}" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
230
|
+
if (!exp) s += `<path class="hit line" d="${d}"/>`;
|
|
231
|
+
} else if (el.type === 'text') {
|
|
232
|
+
const fs = fsOf(el), lines = String(el.text || '').split('\n'), lh = fs * 1.25;
|
|
233
|
+
s += `<text x="${f1(el.x)}" y="${f1(el.y + fs * .98)}" fill="${el.stroke}" font-size="${fs}" font-family='${fontOf()}' font-weight="500">${lines.map((l, i) => `<tspan x="${f1(el.x)}" dy="${i ? lh : 0}">${esc(l) || ' '}</tspan>`).join('')}</text>`;
|
|
234
|
+
if (!exp) s += `<rect class="hit" x="${el.x}" y="${el.y}" width="${el.w}" height="${el.h}"/>`;
|
|
235
|
+
}
|
|
236
|
+
return s;
|
|
237
|
+
}
|
|
238
|
+
const nodes = new Map();
|
|
239
|
+
function paint(el) {
|
|
240
|
+
if (!R) return;
|
|
241
|
+
let g = nodes.get(el.id);
|
|
242
|
+
if (!g) { g = document.createElementNS(NS, 'g'); g.dataset.id = el.id; nodes.set(el.id, g); }
|
|
243
|
+
g.innerHTML = elSVG(el);
|
|
244
|
+
g.classList.toggle('erasing', !!el._erase);
|
|
245
|
+
g.classList.toggle('editing', editing === el.id);
|
|
246
|
+
}
|
|
247
|
+
function full() {
|
|
248
|
+
if (!R) return;
|
|
249
|
+
const keep = new Set(B.els.map(e => e.id));
|
|
250
|
+
[...nodes.keys()].forEach(id => { if (!keep.has(id)) { nodes.get(id).remove(); nodes.delete(id); } });
|
|
251
|
+
B.els.forEach(paint);
|
|
252
|
+
B.els.forEach(el => R.els.appendChild(nodes.get(el.id)));
|
|
253
|
+
sel.forEach(id => { if (!keep.has(id)) sel.delete(id); });
|
|
254
|
+
drawSel(); applyView(); panel(); R.empty.hidden = B.els.length > 0;
|
|
255
|
+
}
|
|
256
|
+
function repaintRelated(ids) {
|
|
257
|
+
const set = new Set(ids);
|
|
258
|
+
B.els.forEach(el => { if (set.has(el.id) || (isLinear(el) && (set.has(el.start) || set.has(el.end)))) paint(el); });
|
|
259
|
+
drawSel();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* ---------- Vista ---------- */
|
|
263
|
+
function applyView() {
|
|
264
|
+
if (!R) return;
|
|
265
|
+
const { x, y, z } = B.view;
|
|
266
|
+
R.world.setAttribute('transform', `translate(${f1(x)} ${f1(y)}) scale(${z})`);
|
|
267
|
+
const g = 22 * z;
|
|
268
|
+
R.stage.style.backgroundSize = B.opts.grid ? `${g}px ${g}px` : '0 0';
|
|
269
|
+
R.stage.style.backgroundPosition = `${x % g}px ${y % g}px`;
|
|
270
|
+
R.stage.classList.toggle('nogrid', !B.opts.grid);
|
|
271
|
+
R.zoomPct.textContent = Math.round(z * 100) + '%';
|
|
272
|
+
if (editing) placeEditor();
|
|
273
|
+
}
|
|
274
|
+
const toWorld = (cx, cy) => { const r = R.svg.getBoundingClientRect(); return [(cx - r.left - B.view.x) / B.view.z, (cy - r.top - B.view.y) / B.view.z]; };
|
|
275
|
+
function zoomAt(z, sx, sy) {
|
|
276
|
+
z = clamp(z, .1, 4);
|
|
277
|
+
const r = R.svg.getBoundingClientRect(), px = (sx ?? r.width / 2), py = (sy ?? r.height / 2);
|
|
278
|
+
const wx = (px - B.view.x) / B.view.z, wy = (py - B.view.y) / B.view.z;
|
|
279
|
+
B.view = { x: px - wx * z, y: py - wy * z, z };
|
|
280
|
+
applyView(); drawSel(); save();
|
|
281
|
+
}
|
|
282
|
+
let anim = 0;
|
|
283
|
+
function animateView(to) {
|
|
284
|
+
cancelAnimationFrame(anim);
|
|
285
|
+
if (reduce) { B.view = to; applyView(); drawSel(); save(); return; }
|
|
286
|
+
const from = { ...B.view }, t0 = performance.now(), D = 380;
|
|
287
|
+
const step = t => {
|
|
288
|
+
const k = Math.min(1, (t - t0) / D), e = 1 - Math.pow(1 - k, 3);
|
|
289
|
+
B.view = { x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e, z: from.z + (to.z - from.z) * e };
|
|
290
|
+
applyView(); drawSel();
|
|
291
|
+
if (k < 1) anim = requestAnimationFrame(step); else save();
|
|
292
|
+
};
|
|
293
|
+
anim = requestAnimationFrame(step);
|
|
294
|
+
}
|
|
295
|
+
function fit(list) {
|
|
296
|
+
list = list && list.length ? list : B.els;
|
|
297
|
+
const r = R.svg.getBoundingClientRect();
|
|
298
|
+
if (!list.length) return animateView({ x: r.width / 2, y: r.height / 2, z: 1 });
|
|
299
|
+
const b = union(list), pad = Math.min(96, r.width * .1);
|
|
300
|
+
const z = clamp(Math.min((r.width - pad * 2) / Math.max(b.w, 1), (r.height - pad * 2) / Math.max(b.h, 1)), .15, 1.6);
|
|
301
|
+
animateView({ x: r.width / 2 - (b.x + b.w / 2) * z, y: r.height / 2 - (b.y + b.h / 2) * z, z });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* ---------- Selecció ---------- */
|
|
305
|
+
const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
|
306
|
+
function drawSel() {
|
|
307
|
+
if (!R) return;
|
|
308
|
+
const z = B.view.z, list = [...sel].map(byId).filter(Boolean);
|
|
309
|
+
let s = '';
|
|
310
|
+
if (marquee) s += `<rect class="marq" x="${marquee.x}" y="${marquee.y}" width="${marquee.w}" height="${marquee.h}" stroke-width="${1 / z}"/>`;
|
|
311
|
+
if (list.length) {
|
|
312
|
+
const p = 6 / z;
|
|
313
|
+
if (list.length === 1 && isLinear(list[0])) {
|
|
314
|
+
const [a, b] = ends(list[0]), r = 6 / z;
|
|
315
|
+
s += `<circle class="hdl" data-ep="0" cx="${a[0]}" cy="${a[1]}" r="${r}" stroke-width="${1.5 / z}"/><circle class="hdl" data-ep="1" cx="${b[0]}" cy="${b[1]}" r="${r}" stroke-width="${1.5 / z}"/>`;
|
|
316
|
+
} else {
|
|
317
|
+
const b = union(list);
|
|
318
|
+
s += `<rect class="selbox" x="${b.x - p}" y="${b.y - p}" width="${b.w + p * 2}" height="${b.h + p * 2}" stroke-width="${1.2 / z}" stroke-dasharray="${4 / z} ${3 / z}"/>`;
|
|
319
|
+
if (list.length === 1 && list[0].type !== 'draw') {
|
|
320
|
+
const H = 9 / z, bx = b.x - p, by = b.y - p, bw = b.w + p * 2, bh = b.h + p * 2;
|
|
321
|
+
const pos = { nw: [bx, by], n: [bx + bw / 2, by], ne: [bx + bw, by], e: [bx + bw, by + bh / 2], se: [bx + bw, by + bh], s: [bx + bw / 2, by + bh], sw: [bx, by + bh], w: [bx, by + bh / 2] };
|
|
322
|
+
HANDLES.forEach(k => { if (list[0].type === 'text' && (k === 'n' || k === 's' || k === 'e' || k === 'w')) return; s += `<rect class="hdl" data-h="${k}" x="${pos[k][0] - H / 2}" y="${pos[k][1] - H / 2}" width="${H}" height="${H}" rx="${2 / z}" stroke-width="${1.5 / z}"/>`; });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (bindHint) { const b = bbox(bindHint); s += `<rect class="bindhint" x="${b.x - 4}" y="${b.y - 4}" width="${b.w + 8}" height="${b.h + 8}" rx="${10}" stroke-width="${2 / z}"/>`; }
|
|
327
|
+
R.sel.innerHTML = s;
|
|
328
|
+
panel();
|
|
329
|
+
}
|
|
330
|
+
function select(ids) { sel = new Set(ids); drawSel(); }
|
|
331
|
+
|
|
332
|
+
/* ---------- Creació ---------- */
|
|
333
|
+
function mk(type, props = {}) {
|
|
334
|
+
const st = B.style;
|
|
335
|
+
return Object.assign({ id: uid(), type, x: 0, y: 0, w: 0, h: 0, stroke: st.stroke, fill: isShapeType(type) ? st.fill : 'none', sw: st.sw, dash: st.dash, fs: st.fs, text: '', seed: Math.floor(Math.random() * 2 ** 31) }, props);
|
|
336
|
+
}
|
|
337
|
+
const isShapeType = t => t === 'rect' || t === 'ellipse' || t === 'diamond';
|
|
338
|
+
|
|
339
|
+
/* ---------- Edició de text ---------- */
|
|
340
|
+
let editing = null;
|
|
341
|
+
function edit(id) {
|
|
342
|
+
const el = byId(id); if (!el || !R) return;
|
|
343
|
+
editing = id; paint(el);
|
|
344
|
+
const ta = R.editor; ta.hidden = false; ta.value = el.text || '';
|
|
345
|
+
ta.dataset.mode = el.type === 'text' ? 'text' : 'center';
|
|
346
|
+
placeEditor();
|
|
347
|
+
requestAnimationFrame(() => { ta.focus(); ta.select(); });
|
|
348
|
+
}
|
|
349
|
+
function placeEditor() {
|
|
350
|
+
const el = byId(editing); if (!el) return;
|
|
351
|
+
const ta = R.editor, z = B.view.z, fs = fsOf(el) * (isLinear(el) ? .8 : 1);
|
|
352
|
+
ta.style.font = `500 ${fs * z}px ${fontOf()}`;
|
|
353
|
+
ta.style.color = el.stroke;
|
|
354
|
+
ta.style.lineHeight = 1.25;
|
|
355
|
+
let x, y, w;
|
|
356
|
+
if (el.type === 'text') { x = el.x; y = el.y; w = Math.max(el.w, 120); ta.style.textAlign = 'left'; }
|
|
357
|
+
else {
|
|
358
|
+
const b = isLinear(el) ? (() => { const [a, c] = ends(el); return { x: (a[0] + c[0]) / 2 - 110, y: (a[1] + c[1]) / 2, w: 220, h: 0 }; })() : bbox(el);
|
|
359
|
+
w = isLinear(el) ? 220 : Math.max(60, textW(el));
|
|
360
|
+
x = b.x + (b.w - w) / 2; y = b.y + b.h / 2; ta.style.textAlign = 'center';
|
|
361
|
+
}
|
|
362
|
+
ta.style.width = w * z + 'px';
|
|
363
|
+
ta.style.height = 'auto';
|
|
364
|
+
ta.style.height = Math.max(fs * 1.25 * z, ta.scrollHeight) + 'px';
|
|
365
|
+
const top = el.type === 'text' ? y * z + B.view.y : y * z + B.view.y - ta.offsetHeight / 2;
|
|
366
|
+
ta.style.transform = `translate(${x * z + B.view.x}px, ${top}px)`;
|
|
367
|
+
}
|
|
368
|
+
function endEdit() {
|
|
369
|
+
if (!editing) return;
|
|
370
|
+
const el = byId(editing), ta = R.editor, v = ta.value.replace(/\s+$/, '');
|
|
371
|
+
editing = null; ta.hidden = true;
|
|
372
|
+
if (!el) return;
|
|
373
|
+
if (el.text !== v) { if (!el._new) record(); el.text = v; }
|
|
374
|
+
delete el._new;
|
|
375
|
+
if (el.type === 'text') { if (!v) { B.els = B.els.filter(e => e !== el); sel.delete(el.id); } else fitText(el); }
|
|
376
|
+
else growToFit(el);
|
|
377
|
+
full(); save();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* ---------- Interacció amb el punter ---------- */
|
|
381
|
+
let drag = null, marquee = null, bindHint = null, lastTap = { t: 0, x: 0, y: 0 };
|
|
382
|
+
const pointers = new Map();
|
|
383
|
+
function shapeAt(cx, cy, except) {
|
|
384
|
+
for (const n of document.elementsFromPoint(cx, cy)) {
|
|
385
|
+
const g = n.closest && n.closest('[data-id]');
|
|
386
|
+
if (g && g.dataset.id !== except) { const el = byId(g.dataset.id); if (isShape(el) || el?.type === 'text') return el; }
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
function onDown(e) {
|
|
391
|
+
if (!R || (e.button !== 0 && e.button !== 1)) return;
|
|
392
|
+
const now = performance.now(), dbl = now - lastTap.t < 380 && Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) < 10;
|
|
393
|
+
lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: e.clientX, y: e.clientY };
|
|
394
|
+
if (dbl && (tool === 'select' || tool === 'text') && pointers.size === 0 && !spaceDown) { e.preventDefault(); onDbl(e); return; }
|
|
395
|
+
if (editing) { endEdit(); if (tool === 'select') return; }
|
|
396
|
+
R.svg.setPointerCapture(e.pointerId);
|
|
397
|
+
pointers.set(e.pointerId, [e.clientX, e.clientY]);
|
|
398
|
+
if (pointers.size === 2) { const [a, b] = [...pointers.values()]; drag = { mode: 'pinch', d0: Math.hypot(a[0] - b[0], a[1] - b[1]), z0: B.view.z, v0: { ...B.view }, m0: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] }; marquee = null; return; }
|
|
399
|
+
const [wx, wy] = toWorld(e.clientX, e.clientY);
|
|
400
|
+
const hitG = e.target.closest('[data-id]'), hit = hitG ? byId(hitG.dataset.id) : null;
|
|
401
|
+
const touch = e.pointerType === 'touch';
|
|
402
|
+
if (e.button === 1 || spaceDown || tool === 'hand') { drag = { mode: 'pan', sx: e.clientX, sy: e.clientY, v0: { ...B.view } }; R.stage.classList.add('panning'); return; }
|
|
403
|
+
if (tool === 'select') {
|
|
404
|
+
const hd = e.target.closest('[data-h]'), ep = e.target.closest('[data-ep]');
|
|
405
|
+
if (hd) { const el = byId([...sel][0]); drag = { mode: 'resize', k: hd.dataset.h, el, o: JSON.parse(JSON.stringify(el)), b0: bbox(el), rec: false }; return; }
|
|
406
|
+
if (ep) { const el = byId([...sel][0]); drag = { mode: 'ep', i: +ep.dataset.ep, el, rec: false }; return; }
|
|
407
|
+
if (hit) {
|
|
408
|
+
if (e.shiftKey) { sel.has(hit.id) ? sel.delete(hit.id) : sel.add(hit.id); drawSel(); }
|
|
409
|
+
else if (!sel.has(hit.id)) select([hit.id]);
|
|
410
|
+
if (e.altKey && sel.size) { record(); const copies = duplicate([...sel], 0); select(copies); }
|
|
411
|
+
drag = { mode: 'move', sx: wx, sy: wy, orig: snapshotSel(), rec: false, moved: false };
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (touch) { drag = { mode: 'pan', sx: e.clientX, sy: e.clientY, v0: { ...B.view }, tapClear: true }; return; }
|
|
415
|
+
if (!e.shiftKey) select([]);
|
|
416
|
+
drag = { mode: 'marquee', sx: wx, sy: wy, base: new Set(sel) }; marquee = { x: wx, y: wy, w: 0, h: 0 };
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (tool === 'eraser') { drag = { mode: 'erase' }; eraseAt(e.clientX, e.clientY); return; }
|
|
420
|
+
record();
|
|
421
|
+
if (tool === 'text') {
|
|
422
|
+
const el = mk('text', { x: wx, y: wy - fsOf(B.style) * .6, _new: true });
|
|
423
|
+
el.fill = 'none'; fitText(el); B.els.push(el); full(); select([el.id]); edit(el.id); if (!lock) setTool('select');
|
|
424
|
+
drag = null; return;
|
|
425
|
+
}
|
|
426
|
+
if (isShapeType(tool)) {
|
|
427
|
+
const el = mk(tool, { x: wx, y: wy }); B.els.push(el); paint(el); R.els.appendChild(nodes.get(el.id)); R.empty.hidden = true;
|
|
428
|
+
drag = { mode: 'create', el, sx: wx, sy: wy }; return;
|
|
429
|
+
}
|
|
430
|
+
if (tool === 'arrow' || tool === 'line') {
|
|
431
|
+
const s = tool === 'arrow' ? shapeAt(e.clientX, e.clientY) : null;
|
|
432
|
+
const el = mk(tool, { x: wx, y: wy, points: [[0, 0], [0, 0]], start: s ? s.id : null, end: null }); el.fill = 'none';
|
|
433
|
+
B.els.push(el); paint(el); R.els.appendChild(nodes.get(el.id)); R.empty.hidden = true;
|
|
434
|
+
drag = { mode: 'line', el }; return;
|
|
435
|
+
}
|
|
436
|
+
if (tool === 'draw') {
|
|
437
|
+
const el = mk('draw', { x: wx, y: wy, points: [[0, 0]] }); el.fill = 'none';
|
|
438
|
+
B.els.push(el); paint(el); R.els.appendChild(nodes.get(el.id)); R.empty.hidden = true;
|
|
439
|
+
drag = { mode: 'draw', el, last: [wx, wy] };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function snapshotSel() { const m = {}; sel.forEach(id => { const el = byId(id); if (el) m[id] = { x: el.x, y: el.y }; }); return m; }
|
|
443
|
+
function onMove(e) {
|
|
444
|
+
if (!R) return;
|
|
445
|
+
if (pointers.has(e.pointerId)) pointers.set(e.pointerId, [e.clientX, e.clientY]);
|
|
446
|
+
if (!drag) {
|
|
447
|
+
if (tool === 'arrow' && e.pointerType === 'mouse') { const s = shapeAt(e.clientX, e.clientY); if (s !== bindHint) { bindHint = s; drawSel(); } }
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const [wx, wy] = toWorld(e.clientX, e.clientY);
|
|
451
|
+
switch (drag.mode) {
|
|
452
|
+
case 'pinch': {
|
|
453
|
+
if (pointers.size < 2) return;
|
|
454
|
+
const [a, b] = [...pointers.values()], d = Math.hypot(a[0] - b[0], a[1] - b[1]), m = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
455
|
+
const r = R.svg.getBoundingClientRect(), z = clamp(drag.z0 * d / drag.d0, .1, 4);
|
|
456
|
+
const wx0 = (drag.m0[0] - r.left - drag.v0.x) / drag.v0.z, wy0 = (drag.m0[1] - r.top - drag.v0.y) / drag.v0.z;
|
|
457
|
+
B.view = { x: m[0] - r.left - wx0 * z, y: m[1] - r.top - wy0 * z, z }; applyView(); drawSel(); return;
|
|
458
|
+
}
|
|
459
|
+
case 'pan': B.view = { ...drag.v0, x: drag.v0.x + e.clientX - drag.sx, y: drag.v0.y + e.clientY - drag.sy }; if (Math.abs(e.clientX - drag.sx) + Math.abs(e.clientY - drag.sy) > 4) drag.tapClear = false; applyView(); drawSel(); return;
|
|
460
|
+
case 'marquee': {
|
|
461
|
+
marquee = { x: Math.min(drag.sx, wx), y: Math.min(drag.sy, wy), w: Math.abs(wx - drag.sx), h: Math.abs(wy - drag.sy) };
|
|
462
|
+
const ids = new Set(drag.base);
|
|
463
|
+
B.els.forEach(el => { const b = bbox(el); if (b.x >= marquee.x && b.y >= marquee.y && b.x + b.w <= marquee.x + marquee.w && b.y + b.h <= marquee.y + marquee.h) ids.add(el.id); });
|
|
464
|
+
sel = ids; drawSel(); return;
|
|
465
|
+
}
|
|
466
|
+
case 'move': {
|
|
467
|
+
let dx = wx - drag.sx, dy = wy - drag.sy;
|
|
468
|
+
if (!drag.moved && Math.hypot(dx, dy) * B.view.z < 3) return;
|
|
469
|
+
if (!drag.rec) { record(); drag.rec = true; }
|
|
470
|
+
drag.moved = true;
|
|
471
|
+
if (e.shiftKey) { if (Math.abs(dx) > Math.abs(dy)) dy = 0; else dx = 0; }
|
|
472
|
+
Object.entries(drag.orig).forEach(([id, o]) => {
|
|
473
|
+
const el = byId(id); if (!el) return;
|
|
474
|
+
if (isLinear(el)) { if ((el.start && !sel.has(el.start)) || (el.end && !sel.has(el.end))) { const [a, b] = ends(el); el.x = a[0]; el.y = a[1]; el.points = [[0, 0], [b[0] - a[0], b[1] - a[1]]]; el.start = el.end = null; drag.orig[id] = { x: el.x - dx, y: el.y - dy }; return; } }
|
|
475
|
+
el.x = o.x + dx; el.y = o.y + dy;
|
|
476
|
+
});
|
|
477
|
+
repaintRelated(Object.keys(drag.orig)); return;
|
|
478
|
+
}
|
|
479
|
+
case 'resize': {
|
|
480
|
+
if (!drag.rec) { record(); drag.rec = true; }
|
|
481
|
+
const { k, b0, o, el } = drag; let { x, y, w, h } = b0;
|
|
482
|
+
if (k.includes('e')) w = Math.max(8, wx - b0.x);
|
|
483
|
+
if (k.includes('s')) h = Math.max(8, wy - b0.y);
|
|
484
|
+
if (k.includes('w')) { w = Math.max(8, b0.x + b0.w - wx); x = b0.x + b0.w - w; }
|
|
485
|
+
if (k.includes('n')) { h = Math.max(8, b0.y + b0.h - wy); y = b0.y + b0.h - h; }
|
|
486
|
+
if ((e.shiftKey || el.type === 'text') && k.length === 2) { const s = Math.max(w / b0.w, h / b0.h); w = b0.w * s; h = b0.h * s; if (k.includes('w')) x = b0.x + b0.w - w; if (k.includes('n')) y = b0.y + b0.h - h; }
|
|
487
|
+
if (el.type === 'text') { el.fs = null; el.fsPx = Math.max(8, fsOf(o) * h / b0.h); el.x = x; el.y = y; fitText(el); }
|
|
488
|
+
else Object.assign(el, { x, y, w, h });
|
|
489
|
+
repaintRelated([el.id]); return;
|
|
490
|
+
}
|
|
491
|
+
case 'ep': {
|
|
492
|
+
if (!drag.rec) { record(); drag.rec = true; }
|
|
493
|
+
const el = drag.el, [a, b] = ends(el); let p = [wx, wy];
|
|
494
|
+
const s = el.type === 'arrow' ? shapeAt(e.clientX, e.clientY, el.id) : null;
|
|
495
|
+
bindHint = s;
|
|
496
|
+
if (drag.i === 0) { el.start = s ? s.id : null; const end = b; el.x = p[0]; el.y = p[1]; el.points = [[0, 0], [end[0] - p[0], end[1] - p[1]]]; }
|
|
497
|
+
else { el.end = s ? s.id : null; el.x = a[0]; el.y = a[1]; el.points = [[0, 0], [p[0] - a[0], p[1] - a[1]]]; }
|
|
498
|
+
paint(el); drawSel(); return;
|
|
499
|
+
}
|
|
500
|
+
case 'create': {
|
|
501
|
+
const el = drag.el; let w = wx - drag.sx, h = wy - drag.sy;
|
|
502
|
+
if (e.shiftKey) { const m = Math.max(Math.abs(w), Math.abs(h)); w = Math.sign(w || 1) * m; h = Math.sign(h || 1) * m; }
|
|
503
|
+
el.x = w < 0 ? drag.sx + w : drag.sx; el.y = h < 0 ? drag.sy + h : drag.sy; el.w = Math.abs(w); el.h = Math.abs(h);
|
|
504
|
+
paint(el); return;
|
|
505
|
+
}
|
|
506
|
+
case 'line': {
|
|
507
|
+
const el = drag.el; let dx = wx - el.x, dy = wy - el.y;
|
|
508
|
+
if (e.shiftKey) { const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 12)) * (Math.PI / 12), L = Math.hypot(dx, dy); dx = Math.cos(a) * L; dy = Math.sin(a) * L; }
|
|
509
|
+
el.points = [[0, 0], [dx, dy]];
|
|
510
|
+
const s = el.type === 'arrow' ? shapeAt(e.clientX, e.clientY, el.id) : null;
|
|
511
|
+
el.end = s && s.id !== el.start ? s.id : null; bindHint = el.end ? s : null;
|
|
512
|
+
paint(el); drawSel(); return;
|
|
513
|
+
}
|
|
514
|
+
case 'draw': {
|
|
515
|
+
const el = drag.el; if (Math.hypot(wx - drag.last[0], wy - drag.last[1]) * B.view.z < 2) return;
|
|
516
|
+
drag.last = [wx, wy]; el.points.push([wx - el.x, wy - el.y]); paint(el); return;
|
|
517
|
+
}
|
|
518
|
+
case 'erase': eraseAt(e.clientX, e.clientY); return;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function eraseAt(cx, cy) {
|
|
522
|
+
const els = document.elementsFromPoint(cx, cy);
|
|
523
|
+
for (const n of els) { const g = n.closest && n.closest('[data-id]'); if (g) { const el = byId(g.dataset.id); if (el && !el._erase) { el._erase = true; paint(el); } } }
|
|
524
|
+
}
|
|
525
|
+
function onUp(e) {
|
|
526
|
+
pointers.delete(e.pointerId);
|
|
527
|
+
if (!drag || !R) return;
|
|
528
|
+
const d = drag; drag = null; R.stage.classList.remove('panning');
|
|
529
|
+
if (d.mode === 'pinch') { if (pointers.size === 1) { const [p] = [...pointers.values()]; drag = { mode: 'pan', sx: p[0], sy: p[1], v0: { ...B.view } }; } save(); return; }
|
|
530
|
+
if (d.mode === 'pan') { if (d.tapClear) select([]); save(); return; }
|
|
531
|
+
if (d.mode === 'marquee') { marquee = null; drawSel(); return; }
|
|
532
|
+
if (d.mode === 'move' && !d.moved) return;
|
|
533
|
+
if (d.mode === 'erase') { const gone = B.els.filter(el => el._erase); if (gone.length) { record(); B.els.forEach(el => delete el._erase); removeIds(gone.map(g => g.id)); } return; }
|
|
534
|
+
bindHint = null;
|
|
535
|
+
if (d.mode === 'create') {
|
|
536
|
+
const el = d.el;
|
|
537
|
+
if (el.w < 6 && el.h < 6) { el.w = el.type === 'ellipse' ? 140 : 160; el.h = el.type === 'diamond' ? 110 : 80; el.x -= el.w / 2; el.y -= el.h / 2; }
|
|
538
|
+
paint(el); select([el.id]); if (!lock) setTool('select');
|
|
539
|
+
} else if (d.mode === 'line') {
|
|
540
|
+
const el = d.el;
|
|
541
|
+
if (Math.hypot(...el.points[1]) < 6 && !el.end) { B.els = B.els.filter(x => x !== el); hist.pop(); full(); }
|
|
542
|
+
else { select([el.id]); if (!lock) setTool('select'); }
|
|
543
|
+
} else if (d.mode === 'draw') {
|
|
544
|
+
const el = d.el; if (el.points.length < 2) el.points.push([.5, .5]);
|
|
545
|
+
paint(el); if (!lock) setTool('select');
|
|
546
|
+
}
|
|
547
|
+
full(); save();
|
|
548
|
+
}
|
|
549
|
+
function onDbl(e) {
|
|
550
|
+
if (!R) return;
|
|
551
|
+
const g = e.target.closest('[data-id]'), el = g && byId(g.dataset.id);
|
|
552
|
+
if (el && el.type !== 'draw') { select([el.id]); edit(el.id); return; }
|
|
553
|
+
if (!el) {
|
|
554
|
+
const [wx, wy] = toWorld(e.clientX, e.clientY); record();
|
|
555
|
+
const t = mk('text', { x: wx, y: wy - fsOf(B.style) * .6, _new: true }); t.fill = 'none'; fitText(t); B.els.push(t); full(); select([t.id]); edit(t.id);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function onWheel(e) {
|
|
559
|
+
if (!R) return;
|
|
560
|
+
e.preventDefault();
|
|
561
|
+
const r = R.svg.getBoundingClientRect();
|
|
562
|
+
if (e.ctrlKey || e.metaKey) zoomAt(B.view.z * Math.exp(-e.deltaY * .0028), e.clientX - r.left, e.clientY - r.top);
|
|
563
|
+
else { B.view.x -= e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX; B.view.y -= e.shiftKey && !e.deltaX ? 0 : e.deltaY; applyView(); drawSel(); save(); }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/* ---------- Accions ---------- */
|
|
567
|
+
function removeIds(ids) {
|
|
568
|
+
const set = new Set(ids);
|
|
569
|
+
B.els.forEach(el => { if (isLinear(el) && (set.has(el.start) || set.has(el.end))) { const [a, b] = ends(el); el.x = a[0]; el.y = a[1]; el.points = [[0, 0], [b[0] - a[0], b[1] - a[1]]]; if (set.has(el.start)) el.start = null; if (set.has(el.end)) el.end = null; } });
|
|
570
|
+
B.els = B.els.filter(el => !set.has(el.id)); ids.forEach(id => sel.delete(id)); full(); save();
|
|
571
|
+
}
|
|
572
|
+
function materialize(el) { if (!isLinear(el)) return el; const [a, b] = ends(el); return { ...el, x: a[0], y: a[1], points: [[0, 0], [b[0] - a[0], b[1] - a[1]]] }; }
|
|
573
|
+
function duplicate(ids, off = 24) {
|
|
574
|
+
const map = {}, out = [];
|
|
575
|
+
ids.forEach(id => { const el = byId(id); if (!el) return; const c = JSON.parse(JSON.stringify(materialize(el))); c.id = uid(); c.seed = Math.floor(Math.random() * 2 ** 31); c.x += off; c.y += off; map[id] = c.id; out.push(c); });
|
|
576
|
+
out.forEach(c => { if (isLinear(c)) { c.start = map[c.start] || null; c.end = map[c.end] || null; } });
|
|
577
|
+
B.els.push(...out); full(); save(); return out.map(c => c.id);
|
|
578
|
+
}
|
|
579
|
+
function paste() {
|
|
580
|
+
record();
|
|
581
|
+
const items = JSON.parse(clip), map = {};
|
|
582
|
+
const out = items.map(i => { const c = JSON.parse(JSON.stringify(i)); map[i.id] = c.id = uid(); c.seed = Math.floor(Math.random() * 2 ** 31); c.x += 30; c.y += 30; return c; });
|
|
583
|
+
out.forEach(c => { if (isLinear(c)) { c.start = map[c.start] || null; c.end = map[c.end] || null; } });
|
|
584
|
+
B.els.push(...out); full(); save(); select(out.map(c => c.id)); clip = JSON.stringify(out.map(materialize));
|
|
585
|
+
}
|
|
586
|
+
function setStyle(k, v) {
|
|
587
|
+
B.style[k] = v;
|
|
588
|
+
if (sel.size) { record(); sel.forEach(id => { const el = byId(id); if (!el) return; if (k === 'fill' && !isShape(el)) return; el[k] = v; if (k === 'fs') { el.fsPx = null; if (el.type === 'text') fitText(el); else growToFit(el); } }); full(); }
|
|
589
|
+
save(); panel();
|
|
590
|
+
}
|
|
591
|
+
function order(dir) {
|
|
592
|
+
if (!sel.size) return; record();
|
|
593
|
+
const picked = B.els.filter(e => sel.has(e.id)), rest = B.els.filter(e => !sel.has(e.id));
|
|
594
|
+
B.els = dir > 0 ? [...rest, ...picked] : [...picked, ...rest]; full(); save();
|
|
595
|
+
}
|
|
596
|
+
function setTool(t) {
|
|
597
|
+
tool = t; if (!R) return;
|
|
598
|
+
R.tools.querySelectorAll('[data-tool]').forEach(b => b.setAttribute('aria-pressed', b.dataset.tool === t));
|
|
599
|
+
R.stage.dataset.tool = t; bindHint = null; if (t !== 'select') { sel.clear(); drawSel(); } else panel();
|
|
600
|
+
}
|
|
601
|
+
function nudge(dx, dy) { if (!sel.size) return; record(); sel.forEach(id => { const el = byId(id); if (el) { el.x += dx; el.y += dy; } }); repaintRelated([...sel]); save(); }
|
|
602
|
+
|
|
603
|
+
/* ---------- Exportació ---------- */
|
|
604
|
+
function exportSVG(list) {
|
|
605
|
+
list = list && list.length ? list : B.els;
|
|
606
|
+
if (!list.length) return null;
|
|
607
|
+
const b = union(list), pad = 32, w = Math.ceil(b.w + pad * 2), h = Math.ceil(b.h + pad * 2);
|
|
608
|
+
const body = list.map(el => `<g>${elSVG(el, true)}</g>`).join('');
|
|
609
|
+
return { w, h, svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="${f1(b.x - pad)} ${f1(b.y - pad)} ${w} ${h}"><rect x="${f1(b.x - pad)}" y="${f1(b.y - pad)}" width="${w}" height="${h}" fill="#f0ede7"/>${body}</svg>` };
|
|
610
|
+
}
|
|
611
|
+
function download(name, blob) { const u = URL.createObjectURL(blob), a = document.createElement('a'); a.href = u; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(u), 1500); }
|
|
612
|
+
function exportAs(kind) {
|
|
613
|
+
const list = sel.size ? [...sel].map(byId) : B.els, stamp = new Date().toISOString().slice(0, 10);
|
|
614
|
+
if (kind === 'json') { download(`nio-pissarra-${stamp}.json`, new Blob([JSON.stringify({ v: 1, els: B.els }, null, 2)], { type: 'application/json' })); return true; }
|
|
615
|
+
const x = exportSVG(list); if (!x) return false;
|
|
616
|
+
if (kind === 'svg') { download(`nio-pissarra-${stamp}.svg`, new Blob([x.svg], { type: 'image/svg+xml' })); return true; }
|
|
617
|
+
const img = new Image(), url = URL.createObjectURL(new Blob([x.svg], { type: 'image/svg+xml' }));
|
|
618
|
+
img.onload = () => { const c = document.createElement('canvas'), s = 2; c.width = x.w * s; c.height = x.h * s; const g = c.getContext('2d'); g.scale(s, s); g.drawImage(img, 0, 0); URL.revokeObjectURL(url); c.toBlob(bl => download(`nio-pissarra-${stamp}.png`, bl), 'image/png'); };
|
|
619
|
+
img.src = url; return true;
|
|
620
|
+
}
|
|
621
|
+
function importJSON(file) {
|
|
622
|
+
file.text().then(t => { const d = JSON.parse(t); if (!d || !Array.isArray(d.els)) throw 0; record(); B.els = d.els; sel.clear(); full(); fit(); save(); say('He importat la pissarra.'); })
|
|
623
|
+
.catch(() => say('Aquest fitxer no és una pissarra de Nio.'));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/* ---------- Diagrames ---------- */
|
|
627
|
+
function nodeSize(label, type, fsKey) {
|
|
628
|
+
const fs = SIZES[fsKey || B.style.fs] || 24, lines = wrap(label, fs, type === 'diamond' ? 150 : type === 'ellipse' ? 200 : 240);
|
|
629
|
+
const tw = Math.max(...lines.map(l => measure(l, fs)), 40), th = lines.length * fs * 1.25;
|
|
630
|
+
if (type === 'rect') return [Math.max(150, Math.ceil(tw + 56)), Math.max(72, Math.ceil(th + 40))];
|
|
631
|
+
if (type === 'ellipse') return [Math.max(160, Math.ceil((tw + 8) / .7 + 8)), Math.max(80, Math.ceil(th * 1.5 + 24))];
|
|
632
|
+
return [Math.max(190, Math.ceil((tw + 6) / .5 + 8)), Math.max(120, Math.ceil(th * 2 + 32))];
|
|
633
|
+
}
|
|
634
|
+
// Fa créixer la figura (des del centre) si el text no hi cap.
|
|
635
|
+
function growToFit(el) {
|
|
636
|
+
if (!isShape(el) || !el.text) return;
|
|
637
|
+
const [w, h] = nodeSize(el.text, el.type, el.fs), [cx, cy] = center(el);
|
|
638
|
+
const nw = Math.max(el.w, w), nh = Math.max(el.h, h);
|
|
639
|
+
if (nw !== el.w || nh !== el.h) { el.w = nw; el.h = nh; el.x = cx - nw / 2; el.y = cy - nh / 2; }
|
|
640
|
+
}
|
|
641
|
+
function guessType(label) {
|
|
642
|
+
const n = norm(label);
|
|
643
|
+
if (/\?$/.test(label.trim())) return 'diamond';
|
|
644
|
+
if (/^(inici|comenc|fi|final|start|end|sortida)\b/.test(n)) return 'ellipse';
|
|
645
|
+
return 'rect';
|
|
646
|
+
}
|
|
647
|
+
function freeSpot() { const b = union(B.els); if (!b) { const r = R.svg.getBoundingClientRect(); return [(r.width / 2 - B.view.x) / B.view.z, (r.height / 2 - B.view.y) / B.view.z, true]; } return [b.x + b.w + 160, b.y + b.h / 2, false]; }
|
|
648
|
+
function findEl(q) {
|
|
649
|
+
const n = norm(q).replace(/^(el|la|els|les|l'|l’)\s*/, '').replace(/^["'«“]|["'»”]$/g, '');
|
|
650
|
+
if (!n) return null;
|
|
651
|
+
const withText = B.els.filter(e => e.text);
|
|
652
|
+
return withText.find(e => norm(e.text) === n) || withText.find(e => norm(e.text).startsWith(n)) || withText.find(e => norm(e.text).includes(n)) || null;
|
|
653
|
+
}
|
|
654
|
+
function addNode(label, type, x, y, extra = {}) {
|
|
655
|
+
type = type || guessType(label);
|
|
656
|
+
const [w, h] = nodeSize(label, type, B.style.fs);
|
|
657
|
+
const el = mk(type, Object.assign({ x: x - w / 2, y: y - h / 2, w, h, text: label.trim() }, extra));
|
|
658
|
+
if (type === 'diamond' && el.fill === 'none') el.fill = '#fbf1bf';
|
|
659
|
+
B.els.push(el); return el;
|
|
660
|
+
}
|
|
661
|
+
function addEdge(a, b, label, type = 'arrow') {
|
|
662
|
+
const el = mk(type, { x: 0, y: 0, points: [[0, 0], [1, 1]], start: a.id, end: b.id, text: label || '' }); el.fill = 'none'; el.dash = false;
|
|
663
|
+
B.els.push(el); return el;
|
|
664
|
+
}
|
|
665
|
+
function layoutGraph(nodesList, edges, dir = 'h', origin) {
|
|
666
|
+
// nivells per camí més llarg des de les arrels (tolerant a cicles)
|
|
667
|
+
const ids = nodesList.map(n => n.id), indeg = Object.fromEntries(ids.map(i => [i, 0])), out = Object.fromEntries(ids.map(i => [i, []]));
|
|
668
|
+
edges.forEach(([a, b]) => { if (a in out && b in indeg) { out[a].push(b); indeg[b]++; } });
|
|
669
|
+
const level = Object.fromEntries(ids.map(i => [i, 0])), queue = ids.filter(i => !indeg[i]);
|
|
670
|
+
if (!queue.length && ids.length) queue.push(ids[0]);
|
|
671
|
+
const seen = new Set(), deg = { ...indeg };
|
|
672
|
+
while (queue.length) {
|
|
673
|
+
const i = queue.shift(); if (seen.has(i)) continue; seen.add(i);
|
|
674
|
+
out[i].forEach(j => { level[j] = Math.max(level[j], level[i] + 1); if (--deg[j] <= 0) queue.push(j); });
|
|
675
|
+
}
|
|
676
|
+
ids.forEach(i => { if (!seen.has(i)) level[i] = Math.max(level[i], 0); });
|
|
677
|
+
const cols = {}; ids.forEach(i => (cols[level[i]] = cols[level[i]] || []).push(i));
|
|
678
|
+
const nMap = Object.fromEntries(nodesList.map(n => [n.id, n]));
|
|
679
|
+
const gapMain = 110, gapCross = 56;
|
|
680
|
+
const levels = Object.keys(cols).map(Number).sort((a, b) => a - b);
|
|
681
|
+
const mainSize = l => Math.max(...cols[l].map(i => dir === 'h' ? nMap[i].w : nMap[i].h));
|
|
682
|
+
let cursor = origin[0];
|
|
683
|
+
if (dir === 'v') cursor = origin[1];
|
|
684
|
+
levels.forEach(l => {
|
|
685
|
+
const list = cols[l], ms = mainSize(l);
|
|
686
|
+
const cross = list.reduce((s, i) => s + (dir === 'h' ? nMap[i].h : nMap[i].w), 0) + gapCross * (list.length - 1);
|
|
687
|
+
let c = (dir === 'h' ? origin[1] : origin[0]) - cross / 2;
|
|
688
|
+
list.forEach(i => {
|
|
689
|
+
const n = nMap[i];
|
|
690
|
+
if (dir === 'h') { n.x = cursor + (ms - n.w) / 2; n.y = c; c += n.h + gapCross; }
|
|
691
|
+
else { n.y = cursor + (ms - n.h) / 2; n.x = c; c += n.w + gapCross; }
|
|
692
|
+
});
|
|
693
|
+
cursor += ms + gapMain;
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
function parseEdges(text) {
|
|
697
|
+
const edges = [], labels = [];
|
|
698
|
+
text.split(/\n|;/).map(s => s.trim()).filter(Boolean).forEach(line => {
|
|
699
|
+
let lbl = '';
|
|
700
|
+
const m = line.match(/^(.*?)\s*[:|]\s*([^:>→-][^>→]*)$/);
|
|
701
|
+
if (m && /->|→|=>/.test(m[1])) { line = m[1]; lbl = m[2].trim(); }
|
|
702
|
+
const parts = line.split(/\s*(?:-+>|→|=>)\s*/).map(s => s.replace(/^["'«]|["'»]$/g, '').trim()).filter(Boolean);
|
|
703
|
+
for (let i = 0; i < parts.length - 1; i++) { edges.push([parts[i], parts[i + 1], i === parts.length - 2 ? lbl : '']); }
|
|
704
|
+
if (parts.length === 1) labels.push(parts[0]);
|
|
705
|
+
});
|
|
706
|
+
return { edges, singles: labels };
|
|
707
|
+
}
|
|
708
|
+
function buildGraph(edgeList, singles = [], dir = 'h') {
|
|
709
|
+
const names = [], seen = new Map();
|
|
710
|
+
const add = n => { const k = norm(n); if (!seen.has(k)) { seen.set(k, n); names.push(n); } return k; };
|
|
711
|
+
edgeList.forEach(([a, b]) => { add(a); add(b); }); singles.forEach(add);
|
|
712
|
+
const [ox, oy, empty] = freeSpot();
|
|
713
|
+
const created = {}, list = [];
|
|
714
|
+
names.forEach(n => { const existing = findEl(n); const el = existing && isShape(existing) ? existing : addNode(n, null, 0, 0); created[norm(n)] = el; if (!existing) list.push(el); });
|
|
715
|
+
const edges = edgeList.map(([a, b, l]) => { const A = created[norm(a)], Bn = created[norm(b)]; addEdge(A, Bn, l); return [A.id, Bn.id]; });
|
|
716
|
+
if (list.length) {
|
|
717
|
+
const origin = empty ? [ox - 0, oy] : [ox, oy];
|
|
718
|
+
layoutGraph(list, edges, dir, origin);
|
|
719
|
+
if (empty) { const u = union(list); list.forEach(n => { if (dir === 'h') n.x -= u.w / 2; else n.y -= u.h / 2; }); }
|
|
720
|
+
}
|
|
721
|
+
return list;
|
|
722
|
+
}
|
|
723
|
+
function mindMap(center, items) {
|
|
724
|
+
const [ox, oy, empty] = freeSpot();
|
|
725
|
+
const cx = empty ? ox : ox + 260, cy = oy;
|
|
726
|
+
const c = addNode(center, 'ellipse', cx, cy, { fill: '#fbf1bf' });
|
|
727
|
+
const out = [c], n = items.length, R0 = 230 + n * 12;
|
|
728
|
+
items.forEach((it, i) => {
|
|
729
|
+
const a = -Math.PI / 2 + (i / n) * Math.PI * 2, el = addNode(it, 'rect', cx + Math.cos(a) * R0 * 1.25, cy + Math.sin(a) * R0 * .8);
|
|
730
|
+
addEdge(c, el, '', 'line'); out.push(el);
|
|
731
|
+
});
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
function orgChart(spec) {
|
|
735
|
+
const pairs = [];
|
|
736
|
+
spec.split(/;|\n/).map(s => s.trim()).filter(Boolean).forEach(part => {
|
|
737
|
+
const m = part.split(/\s*>\s*/); if (m.length < 2) return;
|
|
738
|
+
for (let i = 0; i < m.length - 1; i++) m[i + 1].split(/\s*,\s*|\s+i\s+/).filter(Boolean).forEach(ch => { const parent = m[i].split(/\s*,\s*/).pop(); pairs.push([parent, ch, '']); });
|
|
739
|
+
});
|
|
740
|
+
return pairs.length ? buildGraph(pairs, [], 'v') : [];
|
|
741
|
+
}
|
|
742
|
+
const TEMPLATES = {
|
|
743
|
+
agent: { say: 'Aquí tens com treballa un agent de Nio, de la dada a l’acció.', edges: [['Banc i ERP', 'Lectura', ''], ['Lectura', 'Regles', ''], ['Regles', 'Supera el llindar?', ''], ['Supera el llindar?', 'Aprovació humana', 'sí'], ['Supera el llindar?', 'Acció automàtica', 'no'], ['Aprovació humana', 'Registre', ''], ['Acció automàtica', 'Registre', '']] },
|
|
744
|
+
aprovacio: { say: 'Flux d’aprovació: el que supera el llindar passa per una persona.', edges: [['Inici', 'Tasca detectada', ''], ['Tasca detectada', 'Import > llindar?', ''], ['Import > llindar?', 'Demana aprovació', 'sí'], ['Import > llindar?', 'Executa', 'no'], ['Demana aprovació', 'Aprovada?', ''], ['Aprovada?', 'Executa', 'sí'], ['Aprovada?', 'Arxiva amb motiu', 'no'], ['Executa', 'Fi', ''], ['Arxiva amb motiu', 'Fi', '']] },
|
|
745
|
+
conciliacio: { say: 'Conciliació bancària, pas a pas.', edges: [['Extracte bancari', 'Creuar amb factures', ''], ['Factures ERP', 'Creuar amb factures', ''], ['Creuar amb factures', 'Quadra?', ''], ['Quadra?', 'Marcar conciliat', 'sí'], ['Quadra?', 'Excepció per revisar', 'no'], ['Excepció per revisar', 'Aprovació', '']] }
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
/* ---------- Xat ---------- */
|
|
749
|
+
const SUGG = ['Flux: Comanda -> Validació -> Preparació -> Enviament', 'Mapa mental de Nio: agents, connexions, aprovacions, registre', 'Organigrama: Direcció > Finances, Operacions; Finances > Comptabilitat', 'Plantilla d’aprovació', 'Ajuda'];
|
|
750
|
+
const HELP = `Puc dibuixar i editar per tu. Prova, per exemple:
|
|
751
|
+
• <b>Flux: A -> B -> C</b> (una fletxa per línia, amb <b>: etiqueta</b> si vols)
|
|
752
|
+
• <b>Mapa mental de X: a, b, c</b>
|
|
753
|
+
• <b>Organigrama: Direcció > Finances, Operacions</b>
|
|
754
|
+
• <b>Afegeix un rombe “Quadra?”</b> · <b>Connecta Banc amb ERP</b>
|
|
755
|
+
• <b>Pinta Banc de verd</b> · <b>Reanomena Banc a Caixa</b>
|
|
756
|
+
• <b>Esborra ERP</b> · <b>Ordena-ho</b> · <b>Centra</b> · <b>Desfés</b>
|
|
757
|
+
• <b>Plantilla agent</b>, <b>Plantilla d’aprovació</b>, <b>Plantilla conciliació</b>
|
|
758
|
+
• <b>Exporta PNG</b>, <b>SVG</b> o <b>JSON</b>`;
|
|
759
|
+
function say(html, me) {
|
|
760
|
+
B.chat.push({ me: !!me, html, t: Date.now() }); if (B.chat.length > 200) B.chat.splice(0, B.chat.length - 200);
|
|
761
|
+
save(); drawChat();
|
|
762
|
+
}
|
|
763
|
+
function drawChat() {
|
|
764
|
+
if (!R) return;
|
|
765
|
+
const box = R.msgs;
|
|
766
|
+
box.innerHTML = (B.chat.length ? B.chat : [{ me: false, html: 'Hola, sóc en Nio. Explica’m què vols dibuixar i ho faig per tu. Escriu <b>ajuda</b> per veure exemples.' }])
|
|
767
|
+
.map(m => m.me ? `<div class="m me">${m.html}</div>` : `<div class="m nio">${window.NioBot ? NioBot.html('idle', 'mav') : ''}<div>${m.html}</div></div>`).join('');
|
|
768
|
+
box.scrollTop = box.scrollHeight;
|
|
769
|
+
window.NioBot?.refresh();
|
|
770
|
+
}
|
|
771
|
+
function colorWord(w) { const n = norm(w).replace(/s$/, ''); return NAMED[n] ? n : null; }
|
|
772
|
+
function act(raw) {
|
|
773
|
+
const text = raw.trim(); if (!text) return;
|
|
774
|
+
if (B.opts.brain && B.opts.brain !== 'rules' && window.NioBrains?.list().some((b) => b.id === B.opts.brain)) return aiAct(text);
|
|
775
|
+
ruleAct(text);
|
|
776
|
+
}
|
|
777
|
+
function ruleAct(text) {
|
|
778
|
+
say(esc(text).replace(/\n/g, '<br>'), true);
|
|
779
|
+
const n = norm(text);
|
|
780
|
+
let reply = null, focus = null;
|
|
781
|
+
const done = (msg, els) => { reply = msg; focus = els; };
|
|
782
|
+
try {
|
|
783
|
+
if (/^(ajuda|help|que (pots|saps) fer|com funciona)/.test(n)) reply = HELP;
|
|
784
|
+
else if (/^(desfes|desfer|undo|torna enrere)/.test(n)) reply = undo() ? 'Fet, he desfet l’últim canvi.' : 'No hi ha res per desfer.';
|
|
785
|
+
else if (/^(refes|refer|redo)/.test(n)) reply = redo() ? 'Refet.' : 'No hi ha res per refer.';
|
|
786
|
+
else if (/(esborra|elimina|treu|neteja|buida)(-ho)?\s*(tot|la pissarra)?$/.test(n) && /(tot|neteja|buida|pissarra)/.test(n)) { if (B.els.length) { record(); B.els = []; sel.clear(); full(); reply = 'He netejat la pissarra. Si t’has equivocat, digues <b>desfés</b>.'; } else reply = 'La pissarra ja és buida.'; }
|
|
787
|
+
else if (/^(centra|ajusta|enquadra|mostra-ho tot|zoom)/.test(n)) { fit(); reply = 'Ho tens tot a la vista.'; }
|
|
788
|
+
else if (/^(ordena|organitza|disposa|reorganitza|auto)/.test(n)) {
|
|
789
|
+
const shapes = B.els.filter(isShape), edges = B.els.filter(e => isLinear(e) && e.start && e.end).map(e => [e.start, e.end]);
|
|
790
|
+
if (!shapes.length) reply = 'No hi ha cap forma per ordenar.';
|
|
791
|
+
else { record(); const b = union(shapes); layoutGraph(shapes, edges, /vertical|amunt|avall/.test(n) ? 'v' : 'h', [b.x, b.y + b.h / 2]); full(); done('He reorganitzat el diagrama.', shapes); }
|
|
792
|
+
}
|
|
793
|
+
else if (/^exporta/.test(n)) { const k = /svg/.test(n) ? 'svg' : /json/.test(n) ? 'json' : 'png'; reply = exportAs(k) ? `T’he descarregat la pissarra en ${k.toUpperCase()}.` : 'Encara no hi ha res per exportar.'; }
|
|
794
|
+
else if (/plantilla|com treballa un agent|flux d.?un agent/.test(n)) {
|
|
795
|
+
const k = /aprova/.test(n) ? 'aprovacio' : /concilia/.test(n) ? 'conciliacio' : 'agent';
|
|
796
|
+
record(); const t = TEMPLATES[k], list = buildGraph(t.edges, [], 'h'); done(t.say, list);
|
|
797
|
+
}
|
|
798
|
+
else if (/^(mapa mental|mapa conceptual|mindmap)/.test(n)) {
|
|
799
|
+
const body = text.replace(/^[^:]*?(de|sobre)\s+/i, '').replace(/^(mapa mental|mapa conceptual|mindmap)\s*/i, '');
|
|
800
|
+
const [c, rest] = body.includes(':') ? [body.slice(0, body.indexOf(':')), body.slice(body.indexOf(':') + 1)] : [body, ''];
|
|
801
|
+
const items = rest.split(/,|\n|;/).map(s => s.trim()).filter(Boolean);
|
|
802
|
+
if (!c.trim()) reply = 'Digues-me el tema i les branques: <b>Mapa mental de Nio: agents, aprovacions</b>.';
|
|
803
|
+
else { record(); done(`Mapa mental de «${esc(c.trim())}» amb ${items.length} branques.`, mindMap(c.trim(), items.length ? items : ['Idea 1', 'Idea 2', 'Idea 3'])); }
|
|
804
|
+
}
|
|
805
|
+
else if (/^organigrama/.test(n)) {
|
|
806
|
+
const spec = text.replace(/^organigrama\s*(de [^:]*)?:?\s*/i, '');
|
|
807
|
+
record(); const list = orgChart(spec);
|
|
808
|
+
if (list.length) done('Organigrama fet.', list); else { hist.pop(); reply = 'Fes servir <b>></b> per indicar qui depèn de qui: <b>Organigrama: Direcció > Finances, Operacions</b>.'; }
|
|
809
|
+
}
|
|
810
|
+
else if (/->|→|=>/.test(text)) {
|
|
811
|
+
const body = text.replace(/^\s*(flux|diagrama( de flux)?|proces|procés|graf)\s*(vertical)?\s*:?\s*/i, '');
|
|
812
|
+
const { edges, singles } = parseEdges(body);
|
|
813
|
+
if (edges.length) { record(); const list = buildGraph(edges, singles, /vertical/.test(n) ? 'v' : 'h'); done(list.length ? `Fet: ${list.length} ${list.length === 1 ? 'caixa' : 'caixes'} i ${edges.length} ${edges.length === 1 ? 'fletxa' : 'fletxes'}.` : `He afegit ${edges.length} ${edges.length === 1 ? 'connexió' : 'connexions'}.`, list.length ? list : null); }
|
|
814
|
+
}
|
|
815
|
+
else if (/^(flux|diagrama de flux|proces|passos)\b/.test(n)) {
|
|
816
|
+
const items = text.replace(/^[^:]*:\s*/, '').split(/,|\n|;/).map(s => s.trim()).filter(Boolean);
|
|
817
|
+
if (items.length > 1) { record(); const edges = items.slice(1).map((it, i) => [items[i], it, '']); done(`Flux de ${items.length} passos.`, buildGraph(edges, [], /vertical/.test(n) ? 'v' : 'h')); }
|
|
818
|
+
}
|
|
819
|
+
if (reply == null) {
|
|
820
|
+
let m;
|
|
821
|
+
if ((m = text.match(/^(?:afegeix|crea|posa|dibuixa|fes)\s+(?:un |una |uns |unes )?(rectangle|caixa|quadrat|cercle|el·lipse|elipse|ellipse|oval|rombe|diamant|decisió|decisio|text|nota)s?\s*(?:amb el text\s*|que digui\s*|de\s*)?(.*)$/i))) {
|
|
822
|
+
const kind = norm(m[1]), label = m[2].replace(/^["'«“]|["'»”]$/g, '').trim();
|
|
823
|
+
record();
|
|
824
|
+
const [x, y] = (() => { const r = R.svg.getBoundingClientRect(); return [(r.width / 2 - B.view.x) / B.view.z, (r.height / 2 - B.view.y) / B.view.z]; })();
|
|
825
|
+
let el;
|
|
826
|
+
if (/text|nota/.test(kind)) { el = mk('text', { x, y, text: label || 'Text' }); el.fill = 'none'; fitText(el); el.x -= el.w / 2; B.els.push(el); }
|
|
827
|
+
else el = addNode(label || ' ', /cercle|elipse|ellipse|oval/.test(kind) ? 'ellipse' : /rombe|diamant|decisio/.test(kind) ? 'diamond' : 'rect', x, y);
|
|
828
|
+
if (!label && el.type !== 'text') el.text = '';
|
|
829
|
+
done(label ? `He afegit «${esc(label)}».` : 'Afegit. Fes-hi doble clic per escriure-hi.', [el]);
|
|
830
|
+
}
|
|
831
|
+
else if ((m = text.match(/^(?:connecta|uneix|enllaça|enllaca|relaciona)\s+(.+?)\s+(?:amb|i|a|fins a)\s+(.+?)(?:\s*:\s*(.+))?$/i))) {
|
|
832
|
+
const a = findEl(m[1]), b = findEl(m[2]);
|
|
833
|
+
if (!a || !b) reply = `No trobo ${!a ? `«${esc(m[1])}»` : `«${esc(m[2])}»`} a la pissarra.`;
|
|
834
|
+
else { record(); addEdge(a, b, m[3] || ''); done(`He connectat «${esc(a.text)}» amb «${esc(b.text)}».`, null); }
|
|
835
|
+
}
|
|
836
|
+
else if ((m = text.match(/^(?:canvia|posa|pinta|acoloreix)\s+(?:el color de\s+|el fons de\s+)?(.+?)\s+(?:a|de|en)\s+(?:color\s+)?(\S+)$/i)) && colorWord(m[2])) {
|
|
837
|
+
const fillMode = /fons/.test(norm(text)), c = colorWord(m[2]);
|
|
838
|
+
const targets = /^(tot|totes|tots|la seleccio|seleccio|aixo)$/.test(norm(m[1])) ? (sel.size ? [...sel].map(byId) : B.els) : [findEl(m[1])].filter(Boolean);
|
|
839
|
+
if (!targets.length) reply = `No trobo «${esc(m[1])}».`;
|
|
840
|
+
else { record(); targets.forEach(el => { if (fillMode && isShape(el)) el.fill = NAMED_FILL[c] || el.fill; else { el.stroke = NAMED[c]; if (isShape(el) && el.fill !== 'none') el.fill = NAMED_FILL[c] || el.fill; } }); full(); done(`Fet, ${targets.length === 1 ? `«${esc(targets[0].text || 'l’element')}» ara és` : 'ara són'} de color ${c}.`, null); }
|
|
841
|
+
}
|
|
842
|
+
else if ((m = text.match(/^(?:reanomena|canvia el text de|canvia el nom de|anomena)\s+(.+?)\s+(?:a|per|com)\s+(.+)$/i))) {
|
|
843
|
+
const el = findEl(m[1]);
|
|
844
|
+
if (!el) reply = `No trobo «${esc(m[1])}».`;
|
|
845
|
+
else { record(); const old = el.text; el.text = m[2].replace(/^["'«“]|["'»”]$/g, '').trim(); if (el.type === 'text') fitText(el); else if (isShape(el)) { const [w, h] = nodeSize(el.text, el.type); const cx = el.x + el.w / 2, cy = el.y + el.h / 2; el.w = Math.max(el.w, w); el.h = Math.max(el.h, h); el.x = cx - el.w / 2; el.y = cy - el.h / 2; } full(); done(`«${esc(old)}» ara es diu «${esc(el.text)}».`, null); }
|
|
846
|
+
}
|
|
847
|
+
else if ((m = text.match(/^(?:esborra|elimina|treu|suprimeix)\s+(.+)$/i))) {
|
|
848
|
+
if (/^(la )?seleccio$/.test(norm(m[1]))) { if (sel.size) { record(); removeIds([...sel]); reply = 'He esborrat la selecció.'; } else reply = 'No hi ha res seleccionat.'; }
|
|
849
|
+
else { const el = findEl(m[1]); if (!el) reply = `No trobo «${esc(m[1])}».`; else { record(); removeIds([el.id]); reply = `He esborrat «${esc(el.text)}».`; } }
|
|
850
|
+
}
|
|
851
|
+
else if (/^(selecciona-ho tot|selecciona tot)/.test(n)) { select(B.els.map(e => e.id)); reply = 'Tot seleccionat.'; }
|
|
852
|
+
else if (/^(traç|traz|estil)\s*(a ma|ma|fet a ma)/.test(n) || /^(mode )?(a ma|esbos)/.test(n)) { B.opts.rough = true; B.opts.font = 'hand'; full(); reply = 'Ara dibuixo a mà.'; }
|
|
853
|
+
else if (/^(traç|estil)?\s*(net|pla|recte|polit)/.test(n)) { B.opts.rough = false; B.opts.font = 'clean'; full(); reply = 'Ara dibuixo amb traç net.'; }
|
|
854
|
+
else if (/^(hola|bon dia|bona tarda|bones)/.test(n)) reply = 'Hola! Què dibuixem? Pots demanar-me un flux, un mapa mental o un organigrama.';
|
|
855
|
+
else if (/,/.test(text) && text.split(',').length > 2 && !/\s(i|amb)\s/.test(n)) {
|
|
856
|
+
const items = text.split(',').map(s => s.trim()).filter(Boolean); record();
|
|
857
|
+
done(`L’he entès com un flux de ${items.length} passos.`, buildGraph(items.slice(1).map((it, i) => [items[i], it, '']), [], 'h'));
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
} catch (err) { console.error(err); reply = 'Ui, no m’ha sortit bé. Torna-ho a provar d’una altra manera.'; }
|
|
861
|
+
if (reply == null) reply = 'Encara no sé fer això. Prova amb <b>Flux: A -> B -> C</b>, <b>Mapa mental de X: a, b</b> o escriu <b>ajuda</b>.';
|
|
862
|
+
full(); save();
|
|
863
|
+
if (focus && focus.length) { select(focus.map(e => e.id)); fit(focus); }
|
|
864
|
+
R.chatBot && (R.chatBot.dataset.mood = 'work');
|
|
865
|
+
setTimeout(() => { say(reply); if (R && R.chatBot) R.chatBot.dataset.mood = 'happy'; setTimeout(() => { if (R && R.chatBot) R.chatBot.dataset.mood = 'idle'; }, 1400); }, reduce ? 0 : 280);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/* ---------- Xat amb un model (Ollama o al mòbil) ---------- */
|
|
869
|
+
const SYSTEM = `Ets en Nio, l’assistent d’una pissarra de diagrames. Respon NOMÉS amb un objecte JSON vàlid, sense cap text fora, amb aquesta forma:
|
|
870
|
+
{"reply": "frase curta en català per a l’usuari", "actions": [ ... ]}
|
|
871
|
+
Accions possibles:
|
|
872
|
+
{"op":"flow","edges":[["Origen","Destí","etiqueta opcional"]],"dir":"h"} diagrama de flux; "dir" pot ser "h" (horitzontal) o "v" (vertical). Si un text acaba en "?", surt com a decisió.
|
|
873
|
+
{"op":"mindmap","center":"Tema","items":["Branca","Branca"]}
|
|
874
|
+
{"op":"org","pairs":[["Cap","Persona o equip que en depèn"]]} organigrama de dalt a baix.
|
|
875
|
+
{"op":"add","shape":"rect","text":"..."} shape pot ser rect, ellipse, diamond o text.
|
|
876
|
+
{"op":"connect","from":"text d’un element","to":"text d’un element","label":""}
|
|
877
|
+
{"op":"color","target":"text d’un element","color":"verd"} colors: negre, vermell, verd, blau, taronja, lila, groc, gris.
|
|
878
|
+
{"op":"rename","target":"text d’un element","to":"text nou"}
|
|
879
|
+
{"op":"delete","target":"text d’un element"}
|
|
880
|
+
{"op":"clear"}
|
|
881
|
+
{"op":"layout","dir":"h"}
|
|
882
|
+
Fes servir textos curts (d’una a quatre paraules) i en català, si l’usuari no diu el contrari. Si només et fan una pregunta, respon amb "actions": [].`;
|
|
883
|
+
function boardSummary() {
|
|
884
|
+
const shapes = B.els.filter((e) => (isShape(e) || e.type === 'text') && e.text).slice(0, 60);
|
|
885
|
+
const edges = B.els.filter((e) => isLinear(e) && e.start && e.end).slice(0, 60).map((e) => `${byId(e.start)?.text || '?'} -> ${byId(e.end)?.text || '?'}${e.text ? ` (${e.text})` : ''}`);
|
|
886
|
+
if (!shapes.length) return 'La pissarra és buida.';
|
|
887
|
+
return `Elements: ${shapes.map((e) => `«${e.text}»`).join(', ')}\nFletxes:\n${edges.join('\n') || '(cap)'}`;
|
|
888
|
+
}
|
|
889
|
+
function parsePlan(raw) {
|
|
890
|
+
const t = String(raw || ''), a = t.indexOf('{'), b = t.lastIndexOf('}');
|
|
891
|
+
if (a < 0 || b <= a) return null;
|
|
892
|
+
try { const p = JSON.parse(t.slice(a, b + 1)); return p && typeof p === 'object' ? { reply: String(p.reply || ''), actions: Array.isArray(p.actions) ? p.actions.slice(0, 30) : [] } : null; }
|
|
893
|
+
catch { return null; }
|
|
894
|
+
}
|
|
895
|
+
const str = (v, n = 80) => String(v ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
|
|
896
|
+
function applyPlan(actions) {
|
|
897
|
+
const made = [];
|
|
898
|
+
const [cx, cy] = (() => { const r = R.svg.getBoundingClientRect(); return [(r.width / 2 - B.view.x) / B.view.z, (r.height / 2 - B.view.y) / B.view.z]; })();
|
|
899
|
+
for (const a of actions) {
|
|
900
|
+
if (!a || typeof a !== 'object') continue;
|
|
901
|
+
const dir = a.dir === 'v' ? 'v' : 'h';
|
|
902
|
+
switch (a.op) {
|
|
903
|
+
case 'flow': {
|
|
904
|
+
const edges = (Array.isArray(a.edges) ? a.edges : []).slice(0, 60).filter((e) => Array.isArray(e) && e[0] && e[1]).map((e) => [str(e[0]), str(e[1]), str(e[2] || '', 40)]);
|
|
905
|
+
if (edges.length) made.push(...buildGraph(edges, [], dir));
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
case 'mindmap': { const c = str(a.center); if (c) made.push(...mindMap(c, (Array.isArray(a.items) ? a.items : []).slice(0, 16).map((x) => str(x)).filter(Boolean))); break; }
|
|
909
|
+
case 'org': { const pairs = (Array.isArray(a.pairs) ? a.pairs : []).slice(0, 60).filter((e) => Array.isArray(e) && e[0] && e[1]).map((e) => [str(e[0]), str(e[1]), '']); if (pairs.length) made.push(...buildGraph(pairs, [], 'v')); break; }
|
|
910
|
+
case 'add': {
|
|
911
|
+
const text = str(a.text, 120);
|
|
912
|
+
if (a.shape === 'text') { const el = mk('text', { x: cx, y: cy, text: text || 'Text' }); el.fill = 'none'; fitText(el); el.x -= el.w / 2; B.els.push(el); made.push(el); }
|
|
913
|
+
else made.push(addNode(text || ' ', ['ellipse', 'diamond'].includes(a.shape) ? a.shape : 'rect', cx, cy + made.length * 20));
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
case 'connect': { const f = findEl(str(a.from)), t = findEl(str(a.to)); if (f && t) addEdge(f, t, str(a.label || '', 40)); break; }
|
|
917
|
+
case 'color': { const el = findEl(str(a.target)), c = colorWord(str(a.color)); if (el && c) { el.stroke = NAMED[c]; if (isShape(el) && el.fill !== 'none') el.fill = NAMED_FILL[c] || el.fill; } break; }
|
|
918
|
+
case 'rename': { const el = findEl(str(a.target)); if (el && a.to) { el.text = str(a.to, 120); if (el.type === 'text') fitText(el); } break; }
|
|
919
|
+
case 'delete': { const el = findEl(str(a.target)); if (el) removeIds([el.id]); break; }
|
|
920
|
+
case 'clear': B.els = []; sel.clear(); break;
|
|
921
|
+
case 'layout': { const shapes = B.els.filter(isShape), edges = B.els.filter((e) => isLinear(e) && e.start && e.end).map((e) => [e.start, e.end]); if (shapes.length) { const bb = union(shapes); layoutGraph(shapes, edges, dir, [bb.x, bb.y + bb.h / 2]); made.push(...shapes); } break; }
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return made;
|
|
925
|
+
}
|
|
926
|
+
let thinking = false;
|
|
927
|
+
async function aiAct(text) {
|
|
928
|
+
if (thinking) return;
|
|
929
|
+
say(esc(text).replace(/\n/g, '<br>'), true);
|
|
930
|
+
thinking = true;
|
|
931
|
+
const wait = document.createElement('div');
|
|
932
|
+
wait.className = 'm nio thinking';
|
|
933
|
+
wait.innerHTML = `${window.NioBot ? NioBot.html('work', 'mav') : ''}<div>Pensant…</div>`;
|
|
934
|
+
R.msgs.appendChild(wait); R.msgs.scrollTop = R.msgs.scrollHeight; window.NioBot?.refresh();
|
|
935
|
+
if (R.chatBot) R.chatBot.dataset.mood = 'work';
|
|
936
|
+
let raw, err;
|
|
937
|
+
try { raw = await window.NioBrains.ask(B.opts.brain, [{ role: 'system', content: SYSTEM }, { role: 'user', content: `Pissarra actual:\n${boardSummary()}\n\nPetició de l’usuari: ${text}` }]); }
|
|
938
|
+
catch (e) { err = e; }
|
|
939
|
+
thinking = false; wait.remove();
|
|
940
|
+
if (!R) return;
|
|
941
|
+
if (R.chatBot) R.chatBot.dataset.mood = 'idle';
|
|
942
|
+
if (err) return say(`No he pogut parlar amb el model: ${esc(err.message)}. Pots tornar a <b>Ordres ràpides</b> al selector de dalt.`);
|
|
943
|
+
const plan = parsePlan(raw);
|
|
944
|
+
if (!plan) return say(`${esc(str(raw, 600)) || 'El model no ha respost res.'}<br><span class="dim">(No m’ha tornat cap acció per a la pissarra.)</span>`);
|
|
945
|
+
let made = [];
|
|
946
|
+
if (plan.actions.length) { record(); made = applyPlan(plan.actions); full(); save(); }
|
|
947
|
+
if (made.length) { select(made.map((e) => e.id)); fit(made); }
|
|
948
|
+
say(esc(plan.reply || (plan.actions.length ? 'Fet.' : 'D’acord.')));
|
|
949
|
+
}
|
|
950
|
+
function drawBrains() {
|
|
951
|
+
if (!R) return;
|
|
952
|
+
const sel_ = R.root.querySelector('#bdBrain'); if (!sel_) return;
|
|
953
|
+
const list = window.NioBrains?.list() || [{ id: 'rules', label: 'Ordres ràpides' }];
|
|
954
|
+
const cur = list.some((b) => b.id === B.opts.brain) ? B.opts.brain : 'rules'; // la preferència es manté encara que ara no hi sigui
|
|
955
|
+
sel_.innerHTML = list.map((b) => `<option value="${esc(b.id)}" ${b.id === cur ? 'selected' : ''}>${esc(b.label)}</option>`).join('');
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/* ---------- Panell d'estil ---------- */
|
|
959
|
+
function panel() {
|
|
960
|
+
if (!R) return;
|
|
961
|
+
const list = [...sel].map(byId).filter(Boolean);
|
|
962
|
+
const show = list.length > 0 || (tool !== 'select' && tool !== 'hand' && tool !== 'eraser');
|
|
963
|
+
R.style.hidden = !show;
|
|
964
|
+
R.stage.classList.toggle('has-style', show);
|
|
965
|
+
if (!show) return;
|
|
966
|
+
const cur = list[0] || B.style, anyShape = list.length ? list.some(isShape) : isShapeType(tool);
|
|
967
|
+
const hasText = list.length ? list.some(e => e.type !== 'draw') : tool !== 'draw';
|
|
968
|
+
const sw = v => `<button type="button" class="sw-c" data-st="stroke" data-v="${v[0]}" style="--c:${v[0]}" aria-label="${v[1]}" aria-pressed="${cur.stroke === v[0]}"></button>`;
|
|
969
|
+
const fl = v => `<button type="button" class="sw-c fill ${v[0] === 'none' ? 'none' : ''}" data-st="fill" data-v="${v[0]}" style="--c:${v[0] === 'none' ? 'transparent' : v[0]}" aria-label="${v[1]}" aria-pressed="${(cur.fill || 'none') === v[0]}"></button>`;
|
|
970
|
+
R.style.innerHTML = `
|
|
971
|
+
<div class="grp"><span>Traç</span><div class="row">${STROKES.map(sw).join('')}</div></div>
|
|
972
|
+
${anyShape ? `<div class="grp"><span>Fons</span><div class="row">${FILLS.map(fl).join('')}</div></div>` : ''}
|
|
973
|
+
<div class="grp"><span>Gruix</span><div class="seg2">${[1, 2, 4].map(v => `<button type="button" data-st="sw" data-v="${v}" aria-pressed="${cur.sw === v}" aria-label="Gruix ${v}"><i style="height:${v + .5}px"></i></button>`).join('')}</div></div>
|
|
974
|
+
<div class="grp"><span>Línia</span><div class="seg2"><button type="button" data-st="dash" data-v="0" aria-pressed="${!cur.dash}">Contínua</button><button type="button" data-st="dash" data-v="1" aria-pressed="${!!cur.dash}">Discontínua</button></div></div>
|
|
975
|
+
${hasText ? `<div class="grp"><span>Text</span><div class="seg2">${['s', 'm', 'l'].map(v => `<button type="button" data-st="fs" data-v="${v}" aria-pressed="${(cur.fs || '') === v}">${v.toUpperCase()}</button>`).join('')}</div></div>` : ''}
|
|
976
|
+
${list.length ? `<div class="grp"><span>Accions</span><div class="row acts">
|
|
977
|
+
<button type="button" class="ib" data-bd="dup" title="Duplica (Ctrl+D)">${svgI(ICON.copy)}</button>
|
|
978
|
+
<button type="button" class="ib" data-bd="front" title="Porta al davant (Ctrl+])">${svgI(ICON.up)}</button>
|
|
979
|
+
<button type="button" class="ib" data-bd="back" title="Envia al fons (Ctrl+[)">${svgI(ICON.down)}</button>
|
|
980
|
+
<button type="button" class="ib danger" data-bd="del" title="Esborra (Supr)">${svgI(ICON.trash)}</button></div></div>` : ''}`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/* ---------- Teclat ---------- */
|
|
984
|
+
const typing = e => e.target.closest && e.target.closest('input, textarea, select, [contenteditable]');
|
|
985
|
+
function onKey(e) {
|
|
986
|
+
if (!R || document.querySelector('dialog[open]')) return;
|
|
987
|
+
if (e.key === ' ' && !typing(e)) { if (!spaceDown) { spaceDown = true; R.stage.classList.add('space'); } e.preventDefault(); return; }
|
|
988
|
+
if (typing(e)) {
|
|
989
|
+
if (e.target === R.editor && (e.key === 'Escape' || (e.key === 'Enter' && (e.metaKey || e.ctrlKey)))) { e.preventDefault(); endEdit(); }
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const k = e.key.toLowerCase(), mod = e.metaKey || e.ctrlKey;
|
|
993
|
+
if (!R.root.isConnected) return;
|
|
994
|
+
if (mod && k === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
|
|
995
|
+
if (mod && k === 'y') { e.preventDefault(); redo(); return; }
|
|
996
|
+
if (mod && k === 'a') { e.preventDefault(); setTool('select'); select(B.els.map(x => x.id)); return; }
|
|
997
|
+
if (mod && k === 'd') { e.preventDefault(); if (sel.size) { record(); select(duplicate([...sel])); } return; }
|
|
998
|
+
if (mod && k === 'c') { if (sel.size) { clip = JSON.stringify([...sel].map(byId).filter(Boolean).map(materialize)); e.preventDefault(); } return; }
|
|
999
|
+
if (mod && k === 'x') { if (sel.size) { clip = JSON.stringify([...sel].map(byId).filter(Boolean).map(materialize)); record(); removeIds([...sel]); e.preventDefault(); } return; }
|
|
1000
|
+
if (mod && k === 'v') { if (clip) { e.preventDefault(); paste(); } return; }
|
|
1001
|
+
if (mod && (k === '=' || k === '+')) { e.preventDefault(); zoomAt(B.view.z * 1.2); return; }
|
|
1002
|
+
if (mod && k === '-') { e.preventDefault(); zoomAt(B.view.z / 1.2); return; }
|
|
1003
|
+
if (mod && k === '0') { e.preventDefault(); zoomAt(1); return; }
|
|
1004
|
+
if (mod && k === ']') { e.preventDefault(); order(1); return; }
|
|
1005
|
+
if (mod && k === '[') { e.preventDefault(); order(-1); return; }
|
|
1006
|
+
if (mod) return;
|
|
1007
|
+
if (e.key === 'Escape') { if (R.help.hidden === false) { R.help.hidden = true; return; } select([]); setTool('select'); return; }
|
|
1008
|
+
if (e.key === 'Delete' || e.key === 'Backspace') { if (sel.size) { e.preventDefault(); record(); removeIds([...sel]); } return; }
|
|
1009
|
+
if (e.key === 'Enter' && sel.size === 1) { e.preventDefault(); edit([...sel][0]); return; }
|
|
1010
|
+
if (e.key.startsWith('Arrow') && sel.size) { e.preventDefault(); const s = e.shiftKey ? 10 : 1; nudge(e.key === 'ArrowLeft' ? -s : e.key === 'ArrowRight' ? s : 0, e.key === 'ArrowUp' ? -s : e.key === 'ArrowDown' ? s : 0); return; }
|
|
1011
|
+
if (e.key === '!' || (e.shiftKey && e.code === 'Digit1')) { e.preventDefault(); fit(); return; }
|
|
1012
|
+
if (e.key === '?') { R.help.hidden = !R.help.hidden; return; }
|
|
1013
|
+
if (e.key === '/') { e.preventDefault(); openChat(true); return; }
|
|
1014
|
+
if (k === 'c') { openChat(!R.root.classList.contains('chat-open')); return; }
|
|
1015
|
+
if (k === 'g') { B.opts.grid = !B.opts.grid; applyView(); save(); return; }
|
|
1016
|
+
if (k === 'q') { lock = !lock; R.root.querySelector('[data-bd=lock]').setAttribute('aria-pressed', lock); return; }
|
|
1017
|
+
const t = TOOLS.find(t => t[2].toLowerCase() === k || t[3] === e.key);
|
|
1018
|
+
if (t) { e.preventDefault(); setTool(t[0]); }
|
|
1019
|
+
}
|
|
1020
|
+
function onKeyUp(e) { if (e.key === ' ') { spaceDown = false; R && R.stage.classList.remove('space'); } }
|
|
1021
|
+
|
|
1022
|
+
function openChat(open, focus = true, persist = true) {
|
|
1023
|
+
if (persist) { B.opts.chat = open; save(); }
|
|
1024
|
+
if (!R) return;
|
|
1025
|
+
const wasOpen = R.root.classList.contains('chat-open');
|
|
1026
|
+
R.root.classList.toggle('chat-open', open);
|
|
1027
|
+
R.chat.inert = !open;
|
|
1028
|
+
const t = R.root.querySelector('[data-bd=chat]'); t.setAttribute('aria-pressed', open);
|
|
1029
|
+
if (open && focus) setTimeout(() => R.input.focus(), reduce ? 0 : 280);
|
|
1030
|
+
if (!open && wasOpen && R.chat.contains(document.activeElement)) t.focus({ preventScroll: true });
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/* ---------- Muntatge ---------- */
|
|
1034
|
+
function template() {
|
|
1035
|
+
return `
|
|
1036
|
+
<div class="bd">
|
|
1037
|
+
<div class="bd-stage" id="bdStage" data-tool="select">
|
|
1038
|
+
<svg class="bd-svg" id="bdSvg" role="img" aria-label="Pissarra"><g id="bdWorld"><g id="bdEls"></g><g id="bdSel" class="bd-selg"></g></g></svg>
|
|
1039
|
+
<textarea class="bd-edit" id="bdEdit" hidden spellcheck="false" aria-label="Edita el text"></textarea>
|
|
1040
|
+
<div class="bd-empty" id="bdEmpty"><b>Pissarra en blanc</b><span>Dibuixa amb les eines de dalt o demana-ho a en Nio: «Flux: Banc -> ERP -> Aprovació».</span></div>
|
|
1041
|
+
|
|
1042
|
+
<div class="bd-bar bd-tools" id="bdTools" role="toolbar" aria-label="Eines">
|
|
1043
|
+
${TOOLS.map(t => `<button type="button" class="tb" data-tool="${t[0]}" aria-pressed="${t[0] === tool}" title="${t[1]} — ${t[2]}${t[3] ? ' o ' + t[3] : ''}">${svgI(t[4])}${t[3] ? `<small>${t[3]}</small>` : ''}</button>`).join('')}
|
|
1044
|
+
<span class="sep"></span>
|
|
1045
|
+
<button type="button" class="tb" data-bd="lock" aria-pressed="${lock}" title="Manté l’eina activa — Q">${svgI(ICON.lock)}</button>
|
|
1046
|
+
<button type="button" class="tb only-m" data-bd="stylebtn" title="Estil">${svgI(ICON.style)}</button>
|
|
1047
|
+
</div>
|
|
1048
|
+
|
|
1049
|
+
<div class="bd-style" id="bdStyle" hidden></div>
|
|
1050
|
+
|
|
1051
|
+
<div class="bd-bar bd-zoom">
|
|
1052
|
+
<button type="button" class="ib" data-bd="undo" title="Desfés — Ctrl+Z">${svgI(ICON.undo)}</button>
|
|
1053
|
+
<button type="button" class="ib" data-bd="redo" title="Refés — Ctrl+Shift+Z">${svgI(ICON.redo)}</button>
|
|
1054
|
+
<span class="sep"></span>
|
|
1055
|
+
<button type="button" class="ib" data-bd="zout" title="Allunya — Ctrl+−">${svgI(ICON.minus)}</button>
|
|
1056
|
+
<button type="button" class="pct" data-bd="z100" title="Zoom al 100% — Ctrl+0" id="bdPct">100%</button>
|
|
1057
|
+
<button type="button" class="ib" data-bd="zin" title="Apropa — Ctrl++">${svgI(ICON.plus)}</button>
|
|
1058
|
+
<button type="button" class="ib" data-bd="fit" title="Mostra-ho tot — Shift+1">${svgI(ICON.fit)}</button>
|
|
1059
|
+
</div>
|
|
1060
|
+
|
|
1061
|
+
<div class="bd-bar bd-right">
|
|
1062
|
+
<div class="bd-menuwrap">
|
|
1063
|
+
<button type="button" class="ib" data-bd="menu" aria-haspopup="menu" aria-expanded="false" title="Més opcions">${svgI(ICON.menu)}</button>
|
|
1064
|
+
<div class="bd-menu" id="bdMenu" role="menu" hidden>
|
|
1065
|
+
<button type="button" role="menuitem" data-bd="png">Exporta PNG</button>
|
|
1066
|
+
<button type="button" role="menuitem" data-bd="svg">Exporta SVG</button>
|
|
1067
|
+
<button type="button" role="menuitem" data-bd="json">Desa còpia (JSON)</button>
|
|
1068
|
+
<label role="menuitem">Obre còpia<input type="file" accept="application/json" id="bdImport" hidden></label>
|
|
1069
|
+
<hr>
|
|
1070
|
+
<button type="button" role="menuitem" data-bd="rough">${B.opts.rough ? 'Traç net' : 'Traç a mà'}</button>
|
|
1071
|
+
<button type="button" role="menuitem" data-bd="grid">${B.opts.grid ? 'Amaga la quadrícula' : 'Mostra la quadrícula'} <kbd>G</kbd></button>
|
|
1072
|
+
<button type="button" role="menuitem" data-bd="help">Dreceres de teclat <kbd>?</kbd></button>
|
|
1073
|
+
<hr>
|
|
1074
|
+
<button type="button" role="menuitem" class="danger" data-bd="clear">Neteja la pissarra</button>
|
|
1075
|
+
</div>
|
|
1076
|
+
</div>
|
|
1077
|
+
<button type="button" class="tb chat-t" data-bd="chat" aria-pressed="${B.opts.chat}" title="Xat amb en Nio — C">${window.NioBot ? NioBot.html('idle', 'tbbot') : svgI(ICON.chat)}<span>Nio</span></button>
|
|
1078
|
+
</div>
|
|
1079
|
+
|
|
1080
|
+
<div class="bd-help" id="bdHelp" hidden>
|
|
1081
|
+
<div class="bd-help-in" role="dialog" aria-label="Dreceres de teclat">
|
|
1082
|
+
<header><h3>Dreceres</h3><button type="button" class="ib" data-bd="helpclose" aria-label="Tanca">${svgI(ICON.close)}</button></header>
|
|
1083
|
+
<div class="cols">
|
|
1084
|
+
<dl><dt>Eines</dt>${TOOLS.map(t => `<div><dd>${t[1]}</dd><kbd>${t[2]}</kbd>${t[3] ? `<kbd>${t[3]}</kbd>` : ''}</div>`).join('')}<div><dd>Manté l’eina</dd><kbd>Q</kbd></div></dl>
|
|
1085
|
+
<dl><dt>Edició</dt>
|
|
1086
|
+
<div><dd>Desfés / Refés</dd><kbd>Ctrl Z</kbd><kbd>Ctrl ⇧ Z</kbd></div>
|
|
1087
|
+
<div><dd>Copia · Retalla · Enganxa</dd><kbd>Ctrl C</kbd><kbd>X</kbd><kbd>V</kbd></div>
|
|
1088
|
+
<div><dd>Duplica</dd><kbd>Ctrl D</kbd></div>
|
|
1089
|
+
<div><dd>Duplica arrossegant</dd><kbd>Alt</kbd></div>
|
|
1090
|
+
<div><dd>Selecciona-ho tot</dd><kbd>Ctrl A</kbd></div>
|
|
1091
|
+
<div><dd>Esborra</dd><kbd>Supr</kbd></div>
|
|
1092
|
+
<div><dd>Edita el text</dd><kbd>Enter</kbd><kbd>Doble clic</kbd></div>
|
|
1093
|
+
<div><dd>Mou 1 / 10 px</dd><kbd>Fletxes</kbd><kbd>⇧</kbd></div>
|
|
1094
|
+
<div><dd>Davant / Fons</dd><kbd>Ctrl ]</kbd><kbd>Ctrl [</kbd></div>
|
|
1095
|
+
</dl>
|
|
1096
|
+
<dl><dt>Vista</dt>
|
|
1097
|
+
<div><dd>Mou el llenç</dd><kbd>Espai</kbd><kbd>Roda</kbd></div>
|
|
1098
|
+
<div><dd>Zoom</dd><kbd>Ctrl roda</kbd><kbd>Ctrl ±</kbd></div>
|
|
1099
|
+
<div><dd>Zoom 100%</dd><kbd>Ctrl 0</kbd></div>
|
|
1100
|
+
<div><dd>Mostra-ho tot</dd><kbd>⇧ 1</kbd></div>
|
|
1101
|
+
<div><dd>Quadrícula</dd><kbd>G</kbd></div>
|
|
1102
|
+
<div><dd>Obre el xat / Escriu-hi</dd><kbd>C</kbd><kbd>/</kbd></div>
|
|
1103
|
+
<div><dd>Proporcions / 15°</dd><kbd>⇧ arrossegant</kbd></div>
|
|
1104
|
+
</dl>
|
|
1105
|
+
</div>
|
|
1106
|
+
</div>
|
|
1107
|
+
</div>
|
|
1108
|
+
</div>
|
|
1109
|
+
|
|
1110
|
+
<aside class="bd-chat" id="bdChat" aria-label="Xat amb en Nio">
|
|
1111
|
+
<header><span class="who">${window.NioBot ? NioBot.html('idle', 'hbot') : ''}<span><b>Nio</b><small>Et dibuixa el que li demanis</small></span></span><button type="button" class="ib" data-bd="chat" aria-label="Tanca el xat">${svgI(ICON.close)}</button></header>
|
|
1112
|
+
<label class="brain"><span>Amb</span><select id="bdBrain" aria-label="Què fa servir el xat"></select></label>
|
|
1113
|
+
<div class="msgs" id="bdMsgs" aria-live="polite"></div>
|
|
1114
|
+
<div class="chips" id="bdChips">${SUGG.map(s => `<button type="button" class="chip-s" data-sug="${esc(s)}">${esc(s)}</button>`).join('')}</div>
|
|
1115
|
+
<form class="cin" id="bdForm"><textarea id="bdInput" rows="1" placeholder="Descriu el diagrama que vols…" aria-label="Missatge per a en Nio"></textarea><button type="submit" class="send" aria-label="Envia">${svgI(ICON.send)}</button></form>
|
|
1116
|
+
</aside>
|
|
1117
|
+
</div>`;
|
|
1118
|
+
}
|
|
1119
|
+
function onClick(e) {
|
|
1120
|
+
const t = e.target.closest('[data-tool], [data-bd], [data-st], [data-sug]'); if (!t || !R) return;
|
|
1121
|
+
if (t.dataset.tool) { setTool(t.dataset.tool); if (matchMedia('(max-width: 820px)').matches) R.root.classList.remove('style-open'); return; }
|
|
1122
|
+
if (t.dataset.st) { const k = t.dataset.st, v = t.dataset.v; setStyle(k, k === 'sw' ? +v : k === 'dash' ? v === '1' : v); return; }
|
|
1123
|
+
if (t.dataset.sug) { R.input.value = t.dataset.sug; R.input.dispatchEvent(new Event('input')); if (t.dataset.sug === 'Ajuda' || /^Plantilla/.test(t.dataset.sug)) send(); else R.input.focus(); return; }
|
|
1124
|
+
const a = t.dataset.bd, menu = R.root.querySelector('#bdMenu');
|
|
1125
|
+
if (a !== 'menu') { menu.hidden = true; R.root.querySelector('[data-bd=menu]').setAttribute('aria-expanded', 'false'); }
|
|
1126
|
+
switch (a) {
|
|
1127
|
+
case 'undo': undo(); break; case 'redo': redo(); break;
|
|
1128
|
+
case 'zin': zoomAt(B.view.z * 1.2); break; case 'zout': zoomAt(B.view.z / 1.2); break; case 'z100': zoomAt(1); break;
|
|
1129
|
+
case 'fit': fit(); break;
|
|
1130
|
+
case 'lock': lock = !lock; t.setAttribute('aria-pressed', lock); break;
|
|
1131
|
+
case 'stylebtn': R.root.classList.toggle('style-open'); break;
|
|
1132
|
+
case 'chat': openChat(!R.root.classList.contains('chat-open')); break;
|
|
1133
|
+
case 'menu': menu.hidden = !menu.hidden; t.setAttribute('aria-expanded', !menu.hidden); break;
|
|
1134
|
+
case 'png': case 'svg': case 'json': if (!exportAs(a)) say('Encara no hi ha res per exportar.'); break;
|
|
1135
|
+
case 'rough': B.opts.rough = !B.opts.rough; B.opts.font = B.opts.rough ? 'hand' : 'clean'; B.els.forEach(el => { if (el.type === 'text') fitText(el); }); t.textContent = B.opts.rough ? 'Traç net' : 'Traç a mà'; full(); save(); break;
|
|
1136
|
+
case 'grid': B.opts.grid = !B.opts.grid; applyView(); save(); t.innerHTML = `${B.opts.grid ? 'Amaga la quadrícula' : 'Mostra la quadrícula'} <kbd>G</kbd>`; break;
|
|
1137
|
+
case 'help': R.help.hidden = false; break; case 'helpclose': R.help.hidden = true; break;
|
|
1138
|
+
case 'clear': if (B.els.length && confirm('Vols netejar la pissarra? Ho podràs desfer amb Ctrl+Z.')) { record(); B.els = []; sel.clear(); full(); save(); } break;
|
|
1139
|
+
case 'dup': if (sel.size) { record(); select(duplicate([...sel])); } break;
|
|
1140
|
+
case 'front': order(1); break; case 'back': order(-1); break;
|
|
1141
|
+
case 'del': if (sel.size) { record(); removeIds([...sel]); } break;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
function send() { const v = R.input.value; if (!v.trim()) return; R.input.value = ''; R.input.style.height = ''; act(v); }
|
|
1145
|
+
|
|
1146
|
+
let cleanup = [];
|
|
1147
|
+
function on(t, ev, fn, opt) { t.addEventListener(ev, fn, opt); cleanup.push(() => t.removeEventListener(ev, fn, opt)); }
|
|
1148
|
+
function mount(root) {
|
|
1149
|
+
if (!root) return;
|
|
1150
|
+
if (R && R.root === root && root.isConnected) return;
|
|
1151
|
+
unmount();
|
|
1152
|
+
root.innerHTML = template();
|
|
1153
|
+
R = { root, stage: root.querySelector('#bdStage'), svg: root.querySelector('#bdSvg'), world: root.querySelector('#bdWorld'), els: root.querySelector('#bdEls'), sel: root.querySelector('#bdSel'),
|
|
1154
|
+
editor: root.querySelector('#bdEdit'), empty: root.querySelector('#bdEmpty'), tools: root.querySelector('#bdTools'), style: root.querySelector('#bdStyle'),
|
|
1155
|
+
zoomPct: root.querySelector('#bdPct'), help: root.querySelector('#bdHelp'), msgs: root.querySelector('#bdMsgs'), input: root.querySelector('#bdInput'),
|
|
1156
|
+
chatBot: root.querySelector('.bd-chat .hbot'), chat: root.querySelector('#bdChat') };
|
|
1157
|
+
nodes.clear();
|
|
1158
|
+
const first = !B.els.length && B.view.x === 0 && B.view.y === 0;
|
|
1159
|
+
if (first) { const r = R.svg.getBoundingClientRect(); B.view = { x: r.width / 2, y: r.height / 2, z: 1 }; }
|
|
1160
|
+
on(R.svg, 'pointerdown', onDown); on(window, 'pointermove', onMove); on(window, 'pointerup', onUp); on(window, 'pointercancel', onUp);
|
|
1161
|
+
on(R.svg, 'wheel', onWheel, { passive: false });
|
|
1162
|
+
on(window, 'keydown', onKey); on(window, 'keyup', onKeyUp);
|
|
1163
|
+
on(root, 'click', onClick);
|
|
1164
|
+
on(R.editor, 'input', placeEditor); on(R.editor, 'blur', () => setTimeout(endEdit, 0));
|
|
1165
|
+
on(R.root.querySelector('#bdForm'), 'submit', e => { e.preventDefault(); send(); });
|
|
1166
|
+
on(R.input, 'keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } if (e.key === 'Escape') { e.stopPropagation(); openChat(false); } });
|
|
1167
|
+
on(R.input, 'input', () => { R.input.style.height = ''; R.input.style.height = Math.min(140, R.input.scrollHeight) + 'px'; });
|
|
1168
|
+
on(R.root.querySelector('#bdBrain'), 'change', (e) => { B.opts.brain = e.target.value; save(); say(B.opts.brain === 'rules' ? 'Faig servir les ordres ràpides. Escriu <b>ajuda</b> per veure-les.' : `Ara t’entendré amb <b>${esc(e.target.selectedOptions[0].textContent)}</b>. Demana’m el diagrama com vulguis.`); });
|
|
1169
|
+
on(R.root.querySelector('#bdBrain'), 'focus', drawBrains);
|
|
1170
|
+
on(R.root.querySelector('#bdImport'), 'change', e => { const f = e.target.files[0]; if (f) importJSON(f); e.target.value = ''; });
|
|
1171
|
+
on(document, 'click', e => { if (R && !e.target.closest('.bd-menuwrap')) { const m = R.root.querySelector('#bdMenu'); if (m) m.hidden = true; } });
|
|
1172
|
+
on(window, 'resize', () => { applyView(); drawSel(); });
|
|
1173
|
+
if (document.fonts) document.fonts.load(`500 24px Caveat`).then(() => { if (R) { B.els.forEach(el => { if (el.type === 'text' && el.text) fitText(el); }); full(); } }).catch(() => {});
|
|
1174
|
+
full(); drawChat(); drawBrains(); setTool(tool); openChat(B.opts.chat && !matchMedia('(max-width: 820px)').matches, false, false);
|
|
1175
|
+
window.NioBot?.refresh();
|
|
1176
|
+
}
|
|
1177
|
+
function unmount() { cleanup.forEach(f => f()); cleanup = []; if (editing) endEdit(); R = null; nodes.clear(); drag = null; marquee = null; pointers.clear(); }
|
|
1178
|
+
|
|
1179
|
+
window.NioBoard = { mount, unmount, act, refreshBrains: drawBrains, get count() { return B.els.length; } };
|
|
1180
|
+
})();
|