nio-cat 0.9.0 → 0.9.1
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/package.json +1 -1
- package/src/telegram.js +21 -2
- package/web/app/app.js +122 -12
- package/web/app/style.css +16 -0
- package/web/board.css +50 -0
- package/web/board.js +29 -5
- package/web/index.html +66 -7
- package/web/sw.js +1 -1
package/package.json
CHANGED
package/src/telegram.js
CHANGED
|
@@ -12,6 +12,22 @@ const httpErr = (status, message) => Object.assign(new Error(message), { status
|
|
|
12
12
|
const TOKEN_RE = /^\d{5,15}:[\w-]{30,60}$/;
|
|
13
13
|
const clip = (s, n) => (String(s).length > n ? `${String(s).slice(0, n)}…` : String(s));
|
|
14
14
|
|
|
15
|
+
// Markdown de l'agent -> HTML de Telegram (negreta, cursiva, codi, enllaços, títols i llistes).
|
|
16
|
+
const esc = (t) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
17
|
+
function toHtml(src) {
|
|
18
|
+
const keep = [];
|
|
19
|
+
const hold = (h) => `\u0000${keep.push(h) - 1}\u0000`;
|
|
20
|
+
let t = String(src).replace(/```[^\n`]*\n?([\s\S]*?)```/g, (_, c) => hold(`<pre>${esc(c.replace(/\n$/, ''))}</pre>`));
|
|
21
|
+
t = t.replace(/`([^`\n]+)`/g, (_, c) => hold(`<code>${esc(c)}</code>`));
|
|
22
|
+
t = esc(t);
|
|
23
|
+
t = t.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
|
|
24
|
+
.replace(/^\s*[-*+]\s+/gm, '• ')
|
|
25
|
+
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>')
|
|
26
|
+
.replace(/\*\*([^*\n]+?)\*\*/g, '<b>$1</b>')
|
|
27
|
+
.replace(/(^|[\s(])\*([^*\s][^*\n]*?)\*(?=[\s).,;:!?]|$)/g, '$1<i>$2</i>');
|
|
28
|
+
return t.replace(/\u0000(\d+)\u0000/g, (_, i) => keep[Number(i)]);
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
class Telegram {
|
|
16
32
|
constructor({ cfg, hub, audit, available = () => ['claude', 'codex'] }) {
|
|
17
33
|
this.cfg = cfg;
|
|
@@ -153,7 +169,8 @@ class Telegram {
|
|
|
153
169
|
this.chatOf.set(sid, chat);
|
|
154
170
|
this.hub.send(sid, text, this.hookUrl ? this.hookUrl() : undefined);
|
|
155
171
|
this.audit.add({ type: 'telegram', what: `Missatge de Telegram: ${clip(text, 120)}`, by: `Telegram · ${who}`, session: sid });
|
|
156
|
-
|
|
172
|
+
this.call('sendChatAction', { chat_id: chat, action: 'typing' }).catch(() => {});
|
|
173
|
+
return;
|
|
157
174
|
} catch (e) {
|
|
158
175
|
return this.send(chat, `No he pogut començar: ${e.message}`);
|
|
159
176
|
}
|
|
@@ -211,7 +228,9 @@ class Telegram {
|
|
|
211
228
|
}
|
|
212
229
|
|
|
213
230
|
send(chat, text) {
|
|
214
|
-
|
|
231
|
+
const raw = clip(text, 3500);
|
|
232
|
+
return this.call('sendMessage', { chat_id: chat, text: toHtml(raw), parse_mode: 'HTML', link_preview_options: { is_disabled: true } })
|
|
233
|
+
.catch(() => this.call('sendMessage', { chat_id: chat, text: raw }).catch(() => {}));
|
|
215
234
|
}
|
|
216
235
|
|
|
217
236
|
async download(fileId) {
|
package/web/app/app.js
CHANGED
|
@@ -552,12 +552,60 @@ function computerCard(m, i) {
|
|
|
552
552
|
// Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
|
|
553
553
|
function helpPanel(open) {
|
|
554
554
|
const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
|
|
555
|
+
const k = (t) => `<span class="mono">${t}</span>`;
|
|
556
|
+
const blk = (t) => `<pre class="cmdb"><code>${t}</code></pre>`;
|
|
557
|
+
const rows = (list) => `<div class="cmdlist">${list.map(([c, d]) => `<div><span class="mono">${c}</span><span>${d}</span></div>`).join('')}</div>`;
|
|
558
|
+
const grp = (t) => `<h3 class="help-grp">${t}</h3>`;
|
|
555
559
|
return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
|
|
556
|
-
${
|
|
557
|
-
${q('
|
|
558
|
-
${q('
|
|
559
|
-
${q('
|
|
560
|
-
${q('
|
|
560
|
+
${grp('Per començar')}
|
|
561
|
+
${q('Què és nio', `<p>nio et deixa donar feina als agents d’IA del teu ordinador (Claude Code, Codex, Grok, Antigravity i Ollama) des del mòbil. Els agents <b>treballen a l’ordinador o al servidor</b>, amb els teus comptes. El mòbil és el comandament: hi segueixes la feina en directe i aproves el que facin.</p>`)}
|
|
562
|
+
${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa:</p>${blk('npx nio-cat')}<p>S’obre nio.cat ja connectat. Cal Node.js 18 o més nou. Pots indicar més carpetes: ${k('npx nio-cat ~/projectes')}.</p>`)}
|
|
563
|
+
${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi ${k('nio-…')}.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi. Guarda el codi com una contrasenya: qui el té, pot manar a l’ordinador.</p><p>Per connectar-t’hi des de qualsevol xarxa cal ${k('cloudflared')} a l’ordinador (${k('brew install cloudflared')}). Sense ell, el mòbil ha d’estar a la mateixa wifi. Si reinicies nio, l’adreça del túnel canvia: torna a escanejar el codi.</p>`)}
|
|
564
|
+
${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu ${k('usuari@servidor')}. L’ordinador hi entra, hi engega nio i el servidor surt a la llista. Després, prem <b>Continua al mòbil</b> amb el servidor actiu.</p><p>Des del terminal també es pot: ${k('npx nio-cat ssh usuari@servidor')}.</p>`)}
|
|
565
|
+
${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa ${k('ssh-copy-id usuari@servidor')} a l’ordinador.</li><li>Node.js 18 o més nou.</li><li>Claude Code instal·lat i amb sessió iniciada: entra al servidor i executa ${k('claude')} un cop.</li><li>Linux amb ${k('curl')} o ${k('wget')} (nio hi instal·la ${k('cloudflared')} sol).</li></ul>`)}
|
|
566
|
+
|
|
567
|
+
${grp('Treballar amb els agents')}
|
|
568
|
+
${q('Crear una sessió', `<p>A <b>Sessions</b>, prem <b>Nova sessió</b>: tria l’agent, la carpeta (hi ha un explorador de carpetes de l’ordinador) i el nivell de permisos. Escriu què vols i l’agent es posa a treballar. La resposta et va arribant en directe.</p><p>També pots <b>clonar un repositori de GitHub</b> des de Nova sessió: nio fa servir el ${k('gh')} de l’ordinador (cal haver fet ${k('gh auth login')}).</p>`)}
|
|
569
|
+
${q('Els tres nivells de permisos', `<ul><li><b>Només llegir</b>: l’agent llegeix i proposa, però no toca cap fitxer.</li><li><b>Demana permís</b>: llegeix sol; abans d’editar o executar alguna cosa, t’ho pregunta (Claude Code).</li><li><b>Via lliure</b>: treballa sense preguntar. Fes-ho servir només en carpetes de confiança.</li></ul><p>Es pot canviar enmig d’una sessió amb ${k('/mode')}.</p>`)}
|
|
570
|
+
${q('Aprovacions', `<p>Quan un agent vol editar un fitxer o executar una ordre, surt una targeta amb el que farà. <b>Aprova</b> o <b>rebutja</b> amb un toc. A <b>Aprovacions</b> tens totes les pendents de totes les sessions juntes.</p>`)}
|
|
571
|
+
${q('Comandes amb «/»', `<p>Al missatge d’una sessió, escriu ${k('/')} i surt la llista amb suggeriments. Les de nio funcionen sempre; les de l’agent (comandes i skills) se li envien tal qual.</p>${rows([
|
|
572
|
+
['/mode', 'Canvia els permisos: ' + k('l') + ' llegir, ' + k('p') + ' preguntar, ' + k('t') + ' tot'],
|
|
573
|
+
['/model', 'Canvia de model'],
|
|
574
|
+
['/stop', 'Atura l’agent ara mateix'],
|
|
575
|
+
['/nova', 'Nova sessió amb el mateix agent i carpeta'],
|
|
576
|
+
['/carpeta', 'Canvia la carpeta de la sessió'],
|
|
577
|
+
['/git', 'Git i GitHub: veure canvis, fer commit i crear un PR'],
|
|
578
|
+
['/nom', 'Posa nom a la sessió (' + k('/nom Feina web') + ')'],
|
|
579
|
+
['/opcions', 'Opcions de la sessió'],
|
|
580
|
+
['/ajuda', 'Llista de comandes']
|
|
581
|
+
])}<p>Exemple: ${k('/mode p')} passa la sessió a «Demana permís».</p>`)}
|
|
582
|
+
${q('Git i GitHub', `<p>Dins una sessió, ${k('/git')} (o Opcions → Git i GitHub) mostra els canvis, fa <b>commit</b> (en una branca nova si ets a ${k('main')}) i crea un <b>PR</b>. Cap clau no passa per nio: fa servir el ${k('git')} i el ${k('gh')} de l’ordinador.</p>`)}
|
|
583
|
+
${q('Avisos al mòbil', `<p>A <b>Ajustos → Avisos</b>, prem <b>Activa els avisos</b>. Rebràs una notificació quan un agent et demana permís o acaba la feina. L’avís arriba buit; el detall el demana el mòbil directament a l’ordinador.</p><p>A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>`)}
|
|
584
|
+
${q('Veu', `<p>A <b>Ajustos → Veu</b> pots posar la teva clau d’ElevenLabs per <b>dictar</b> els missatges amb el micròfon i <b>escoltar</b> les respostes. La clau es queda només en aquest navegador.</p>`)}
|
|
585
|
+
|
|
586
|
+
${grp('Eines')}
|
|
587
|
+
${q('Pantalla remota', `<p>Mira i controla l’escriptori d’un ordinador des del mòbil. A <b>Ordinadors</b>, baixa fins a la secció <b>Pantalla</b> i prem <b>Obre la pantalla</b>. S’obre a pantalla completa. Cal activar abans <b>Apps de l’escriptori</b> a la mateixa secció.</p><ul><li><b>Mirar</b>: només veus la pantalla, en directe. Amb un dit la mous quan has fet zoom.</li><li><b>Controlar</b>: toc = clic; doble toc = doble clic; mantenir premut = clic dret; dos dits = desplaçar o fer zoom. El botó <b>1 dit</b> canvia entre desplaçar i arrossegar, i el botó <b>2 dits</b> entre desplaçar l’ordinador i fer zoom amb pessics. <b>Teclat</b> obre el teclat del mòbil, i dalt hi tens ${k('⌘')} ${k('⌥')} ${k('⌃')} ${k('⇧')}, ${k('Esc')}, ${k('Tab')} i fletxes.</li><li><b>Zoom amb dos dits</b>: pessiga per apropar i arrossega amb dos dits per moure’t. Al mode Mirar sempre va així, amb doble toc per alternar entre enquadrar i apropar. Al mode Controlar, tria al botó <b>2 dits</b> si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o ${k('Ctrl')} + roda. Els botons <b>− 100% +</b> i <b>1:1</b> fan el mateix i funcionen en tots dos modes.</li><li><b>Desa captura</b>: baixa el fotograma actual com a imatge. <b>Captura</b> (a la secció) en fa una de sola, sense obrir la pantalla.</li><li><b>Si surt negra</b>: al Mac, dona el permís de <b>Gravació de pantalla</b> (i <b>Accessibilitat</b> per controlar) al Terminal on corre <span class="mono">nio-cat</span> i reinicia’l. A Linux, per controlar cal <span class="mono">xdotool</span>.</li></ul>`)}
|
|
588
|
+
${q('Pissarra', `<p>Un llenç infinit per fer diagrames a mà alçada: fluxos, mapes mentals i organigrames. Hi arribes des de <b>Més → Pissarra</b> (al mòbil) o al menú (a l’ordinador).</p><ul><li><b>Eines</b>: selecciona, mou el llenç, rectangle, el·lipse, rombe, fletxa, línia, llapis, text i goma. El cadenat manté l’eina activa.</li><li><b>Zoom amb dos dits</b>: al mòbil, pessiga sobre el llenç; amb els dos dits alhora també el mous. A l’ordinador, pessiga al trackpad o fes ${k('Ctrl')} + roda del ratolí.</li><li><b>Moure’s</b>: amb un dit, a l’espai buit (eina Selecciona), o amb ${k('Espai')} + arrossegar, o la roda del ratolí.</li><li><b>Text</b>: doble toc o doble clic per editar; doble toc a l’espai buit crea un text nou.</li><li><b>Zoom ràpid</b>: la xifra del percentatge torna al 100%; el botó de les cantonades ho enquadra tot.</li><li><b>Desfés / refés</b>: els botons de dalt a l’esquerra, o ${k('Ctrl Z')} i ${k('Ctrl ⇧ Z')}.</li><li><b>Estil</b>: color, gruix i traç (al mòbil, el botó de la paleta). Menú ⋯: exporta a PNG, SVG o JSON, i quadrícula.</li></ul>`)}
|
|
589
|
+
${q('Demanar-li el dibuix a en Nio', `<p>Prem <b>Nio</b> (dalt a la dreta) per obrir el xat de la pissarra. Descriu què vols i ho dibuixa. Exemples:</p>${rows([
|
|
590
|
+
['Flux: A -> B -> C', 'Una fletxa per línia; afegeix ' + k(': etiqueta') + ' si vols'],
|
|
591
|
+
['Mapa mental de X: a, b, c', 'Tema central amb branques'],
|
|
592
|
+
['Organigrama: Direcció > Finances, Operacions', 'Qui depèn de qui'],
|
|
593
|
+
['Afegeix un rombe “Quadra?”', 'Afegeix una figura'],
|
|
594
|
+
['Connecta Banc amb ERP', 'Uneix dues figures amb una fletxa'],
|
|
595
|
+
['Pinta Banc de verd', 'Canvia el color'],
|
|
596
|
+
['Reanomena Banc a Caixa', 'Canvia el text'],
|
|
597
|
+
['Esborra ERP', 'Treu una figura'],
|
|
598
|
+
['Ordena-ho · Centra · Desfés', 'Ordena, enquadra o desfà'],
|
|
599
|
+
['Plantilla agent', 'També ' + k('Plantilla d’aprovació') + ' i ' + k('Plantilla conciliació')],
|
|
600
|
+
['Exporta PNG', 'També ' + k('SVG') + ' o ' + k('JSON')]
|
|
601
|
+
])}<p>Al desplegable <b>Amb</b> tries què t’entén: les <b>ordres ràpides</b> (sense gastar res) o un model.</p>`)}
|
|
602
|
+
${q('Xat local', `<p>A <b>Més → Xat local</b> tens un model petit que funciona <b>dins del navegador del mòbil</b>, sense ordinador ni connexió un cop descarregat. Necessita un navegador amb WebGPU. Si la GPU del mòbil es reinicia, el xat es recupera sol.</p>`)}
|
|
603
|
+
${q('Models d’Ollama', `<p>A <b>Models</b> veus els models d’Ollama de l’ordinador, en pots descarregar de nous amb el progrés en directe, esborrar-los i obrir-hi sessions. Funcionen al teu ordinador i no gasten res.</p>`)}
|
|
604
|
+
${q('Buscador i dreceres', `<p>Prem ${k('Ctrl K')} (o ${k('⌘ K')}) per obrir el buscador d’accions: nova sessió, afegir ordinador, configurar la veu, fer el tutorial… Al mòbil, llisca cap als costats per canviar de pàgina.</p>`)}
|
|
605
|
+
|
|
606
|
+
${grp('Si alguna cosa no va')}
|
|
607
|
+
${q('No es connecta', `<ul><li><b>«No arribo a l’ordinador»</b>: comprova que nio continua engegat al terminal.</li><li><b>El servidor ha deixat de respondre</b>: si s’ha reiniciat, l’adreça canvia. Prem <b>Torna a connectar</b> a la seva targeta.</li><li><b>Safari no connecta</b>: obre nio amb ${k('npx nio-cat --local')}, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou. Per invalidar tots els codis antics: ${k('npx nio-cat reset-token')}.</li><li><b>Quins agents té l’ordinador?</b> ${k('npx nio-cat doctor')} els llista.</li></ul>`)}
|
|
608
|
+
${q('Seguretat', `<ul><li>L’enllaç de connexió porta una clau aleatòria que el navegador no envia a cap servidor web.</li><li>Cap dada no passa per servidors de nio: el mòbil parla directament amb el teu ordinador.</li><li>Totes les crides demanen la clau, que només tens tu.</li></ul>`)}
|
|
561
609
|
${q('D’on es gasta', `<p>nio no fa cap crida pel seu compte. Cada agent treballa a l’ordinador o al servidor amb el teu compte (Claude, Codex, Grok…). Ollama funciona al teu ordinador i no gasta res.</p>`)}
|
|
562
610
|
</div></section>`;
|
|
563
611
|
}
|
|
@@ -1870,12 +1918,12 @@ const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageD
|
|
|
1870
1918
|
async function remoteOpen() {
|
|
1871
1919
|
const m = machine(); if (!m || RD.on) return;
|
|
1872
1920
|
if (!(state.info?.control?.escriptori?.enabled || screen?.caps?.escriptori?.enabled)) return toast('Activa primer «Apps de l’escriptori» en aquest ordinador.', 5000);
|
|
1873
|
-
Object.assign(RD, { on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
|
|
1921
|
+
Object.assign(RD, { two: 'scroll', z: 1, px: 0, py: 0, on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
|
|
1874
1922
|
const root = document.createElement('div');
|
|
1875
1923
|
root.className = 'rd'; root.id = 'rd'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'Pantalla remota');
|
|
1876
1924
|
root.innerHTML = `<div class="rd-bar"><button class="rd-btn" data-rd="close" type="button">Tanca</button><b class="rd-title">${esc(m.name || 'Ordinador')}</b><span class="rd-stat" id="rdStat"></span><span class="grow"></span><button class="rd-btn" data-rd="full" type="button" id="rdFull" hidden>Pantalla completa</button><div class="rd-seg" role="group" aria-label="Mode"><button data-rd="view" type="button" aria-pressed="true">Mirar</button><button data-rd="ctl" type="button" aria-pressed="false">Controlar</button></div></div>
|
|
1877
|
-
<div class="rd-stage" id="rdStage"><img id="rdImg" alt="Pantalla de l’ordinador" draggable="false"><div class="rd-msg" id="rdMsg">Connectant…</div></div>
|
|
1878
|
-
<div class="rd-keys" id="rdKeys" hidden><button class="rd-btn" data-rd="kbd" type="button">Teclat</button>${RD_MODS.map(([k, l]) => `<button class="rd-btn" data-mod="${k}" type="button" aria-pressed="false">${l}</button>`).join('')}${RD_KEYS.map(([k, l]) => `<button class="rd-btn" data-key="${k}" type="button">${l}</button>`).join('')}<button class="rd-btn" data-rd="drag" type="button" id="rdDrag">1 dit: desplaça</button></div>
|
|
1925
|
+
<div class="rd-stage" id="rdStage"><img id="rdImg" alt="Pantalla de l’ordinador" draggable="false"><div class="rd-msg" id="rdMsg">Connectant…</div><div class="rd-tools" role="group" aria-label="Zoom i captura"><button class="rd-btn" data-rd="zout" type="button" aria-label="Allunya">−</button><button class="rd-btn rd-pct" data-rd="zfit" type="button" id="rdPct" aria-label="Enquadra">100%</button><button class="rd-btn" data-rd="zin" type="button" aria-label="Apropa">+</button><button class="rd-btn" data-rd="z11" type="button" aria-label="Mida real">1:1</button><span class="rd-sep"></span><button class="rd-btn" data-rd="save" type="button">Desa captura</button></div></div>
|
|
1926
|
+
<div class="rd-keys" id="rdKeys" hidden><button class="rd-btn" data-rd="kbd" type="button">Teclat</button>${RD_MODS.map(([k, l]) => `<button class="rd-btn" data-mod="${k}" type="button" aria-pressed="false">${l}</button>`).join('')}${RD_KEYS.map(([k, l]) => `<button class="rd-btn" data-key="${k}" type="button">${l}</button>`).join('')}<button class="rd-btn" data-rd="drag" type="button" id="rdDrag">1 dit: desplaça</button><button class="rd-btn" data-rd="two" type="button" id="rdTwo">2 dits: desplaça</button></div>
|
|
1879
1927
|
<textarea class="rd-type" id="rdType" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" aria-label="Teclat de l’ordinador"></textarea>`;
|
|
1880
1928
|
document.body.appendChild(root);
|
|
1881
1929
|
document.documentElement.classList.add('rd-open');
|
|
@@ -1912,7 +1960,7 @@ async function rdLoop() {
|
|
|
1912
1960
|
const started = performance.now();
|
|
1913
1961
|
try {
|
|
1914
1962
|
const stage = $('#rdStage'); if (!stage) break;
|
|
1915
|
-
const w = Math.min(
|
|
1963
|
+
const w = Math.min(2400, Math.max(640, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2) * Math.min(RD.z, 2))));
|
|
1916
1964
|
const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
|
|
1917
1965
|
if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || `Error ${res.status}`); }
|
|
1918
1966
|
const blob = await res.blob();
|
|
@@ -1958,9 +2006,64 @@ function rdScroll(dx, dy, pos) {
|
|
|
1958
2006
|
if (ev.dx || ev.dy) rdSend(ev);
|
|
1959
2007
|
}, 70);
|
|
1960
2008
|
}
|
|
2009
|
+
|
|
2010
|
+
function rdView() {
|
|
2011
|
+
const img = $('#rdImg'), st = $('#rdStage'); if (!img || !st) return;
|
|
2012
|
+
const z = RD.z, mx = Math.max(0, (img.offsetWidth * z - st.clientWidth) / 2), my = Math.max(0, (img.offsetHeight * z - st.clientHeight) / 2);
|
|
2013
|
+
RD.px = Math.max(-mx, Math.min(mx, RD.px)); RD.py = Math.max(-my, Math.min(my, RD.py));
|
|
2014
|
+
if (z === 1) RD.px = RD.py = 0;
|
|
2015
|
+
img.style.transform = z === 1 ? '' : `translate(${RD.px}px,${RD.py}px) scale(${z})`;
|
|
2016
|
+
const pct = $('#rdPct'); if (pct) pct.textContent = Math.round(z * 100) + '%';
|
|
2017
|
+
}
|
|
2018
|
+
function rdZoom(z, cx, cy) {
|
|
2019
|
+
const st = $('#rdStage'); if (!st) return;
|
|
2020
|
+
z = Math.max(1, Math.min(6, z));
|
|
2021
|
+
const r = st.getBoundingClientRect(), ox = (cx ?? r.left + r.width / 2) - (r.left + r.width / 2), oy = (cy ?? r.top + r.height / 2) - (r.top + r.height / 2), k = z / RD.z;
|
|
2022
|
+
RD.px = ox - (ox - RD.px) * k; RD.py = oy - (oy - RD.py) * k; RD.z = z; rdView();
|
|
2023
|
+
}
|
|
2024
|
+
function rdSave() {
|
|
2025
|
+
const img = $('#rdImg'); if (!img?.src) return toast('Encara no hi ha cap imatge');
|
|
2026
|
+
const d = new Date(), p = (n) => String(n).padStart(2, '0');
|
|
2027
|
+
const a = document.createElement('a'); a.href = img.src; a.download = `nio-pantalla-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.jpg`;
|
|
2028
|
+
document.body.appendChild(a); a.click(); a.remove(); toast('Captura desada');
|
|
2029
|
+
}
|
|
2030
|
+
function rdGestures(stage) {
|
|
2031
|
+
const pts = new Map(); let g = null, pan = null, tapT = 0;
|
|
2032
|
+
const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
|
|
2033
|
+
const mid = () => { const [a, b] = [...pts.values()]; return [(a.x + b.x) / 2, (a.y + b.y) / 2]; };
|
|
2034
|
+
stage.addEventListener('pointerdown', (e) => {
|
|
2035
|
+
if (e.target.closest('.rd-tools') || (RD.ctl && (RD.two !== 'zoom' || e.pointerType === 'mouse'))) return;
|
|
2036
|
+
try { stage.setPointerCapture(e.pointerId); } catch {}
|
|
2037
|
+
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
2038
|
+
if (pts.size === 2) { const [mx, my] = mid(); g = { d: dist(), z: RD.z, mx, my, px: RD.px, py: RD.py }; pan = null; }
|
|
2039
|
+
else if (pts.size === 1 && !RD.ctl) pan = { x: e.clientX, y: e.clientY, px: RD.px, py: RD.py };
|
|
2040
|
+
});
|
|
2041
|
+
stage.addEventListener('pointermove', (e) => {
|
|
2042
|
+
const p = pts.get(e.pointerId); if (!p) return; p.x = e.clientX; p.y = e.clientY;
|
|
2043
|
+
if (pts.size >= 2 && g) {
|
|
2044
|
+
const [mx, my] = mid(), st = stage.getBoundingClientRect(), z = Math.max(1, Math.min(6, g.z * dist() / g.d)), k = z / g.z;
|
|
2045
|
+
const ox = g.mx - (st.left + st.width / 2), oy = g.my - (st.top + st.height / 2);
|
|
2046
|
+
RD.z = z; RD.px = g.px * k + ox * (1 - k) + (mx - g.mx); RD.py = g.py * k + oy * (1 - k) + (my - g.my); rdView();
|
|
2047
|
+
} else if (pan && RD.z > 1 && !RD.ctl) { RD.px = pan.px + e.clientX - pan.x; RD.py = pan.py + e.clientY - pan.y; rdView(); }
|
|
2048
|
+
});
|
|
2049
|
+
const up = (e) => {
|
|
2050
|
+
if (!pts.has(e.pointerId)) return;
|
|
2051
|
+
const moved = pan && Math.hypot(e.clientX - pan.x, e.clientY - pan.y) > 8;
|
|
2052
|
+
pts.delete(e.pointerId); g = null;
|
|
2053
|
+
if (pts.size === 1 && !RD.ctl) { const [q] = pts.values(); pan = { x: q.x, y: q.y, px: RD.px, py: RD.py }; }
|
|
2054
|
+
else { pan = null; if (!pts.size && e.type === 'pointerup' && !moved && !RD.ctl) { const now = performance.now(); if (now - tapT < 320) { RD.z > 1 ? (RD.z = 1, rdView()) : rdZoom(2.5, e.clientX, e.clientY); tapT = 0; } else tapT = now; } }
|
|
2055
|
+
};
|
|
2056
|
+
stage.addEventListener('pointerup', up); stage.addEventListener('pointercancel', up);
|
|
2057
|
+
stage.addEventListener('wheel', (e) => { if (!(e.ctrlKey || e.metaKey) && RD.ctl) return; e.preventDefault(); if (e.ctrlKey || e.metaKey) rdZoom(RD.z * Math.exp(-Math.max(-40, Math.min(40, e.deltaY)) * 0.01), e.clientX, e.clientY); else if (RD.z > 1) { RD.px -= e.deltaX; RD.py -= e.deltaY; rdView(); } }, { passive: false });
|
|
2058
|
+
let gz = 1;
|
|
2059
|
+
stage.addEventListener('gesturestart', (e) => { e.preventDefault(); gz = RD.z; });
|
|
2060
|
+
stage.addEventListener('gesturechange', (e) => { e.preventDefault(); rdZoom(gz * e.scale, e.clientX, e.clientY); });
|
|
2061
|
+
window.addEventListener('resize', rdView);
|
|
2062
|
+
}
|
|
1961
2063
|
function rdBind(root) {
|
|
1962
2064
|
const stage = $('#rdStage', root), typer = $('#rdType', root);
|
|
1963
2065
|
const ptrs = new Map();
|
|
2066
|
+
rdGestures(stage);
|
|
1964
2067
|
const click = (pos, e) => {
|
|
1965
2068
|
const now = performance.now(), prev = RD.tap, near = prev && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 28;
|
|
1966
2069
|
const count = prev && near && now - prev.t < 340 ? (prev.count >= 2 ? 3 : 2) : 1;
|
|
@@ -1968,12 +2071,13 @@ function rdBind(root) {
|
|
|
1968
2071
|
rdSend({ type: 'click', x: pos[0], y: pos[1], count });
|
|
1969
2072
|
};
|
|
1970
2073
|
stage.addEventListener('pointerdown', (e) => {
|
|
1971
|
-
if (!RD.ctl || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
|
2074
|
+
if (!RD.ctl || e.target.closest('.rd-tools') || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
|
1972
2075
|
try { stage.setPointerCapture(e.pointerId); } catch {}
|
|
1973
2076
|
const p = { x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY, touch: e.pointerType !== 'mouse', moved: false, long: false, path: [], pt: 0, lp: null };
|
|
1974
2077
|
const pos = rdPos(e); if (pos) p.path.push(pos);
|
|
1975
2078
|
if (p.touch) p.lp = setTimeout(() => { if (!p.moved && ptrs.size === 1 && ptrs.has(e.pointerId)) { p.long = true; navigator.vibrate?.(15); const q = rdPos(e); if (q) rdSend({ type: 'rclick', x: q[0], y: q[1] }); } }, 550);
|
|
1976
2079
|
ptrs.set(e.pointerId, p);
|
|
2080
|
+
if (RD.two === 'zoom' && p.touch && ptrs.size > 1) for (const q of ptrs.values()) { q.moved = q.zoomed = true; clearTimeout(q.lp); }
|
|
1977
2081
|
if (!typer.matches(':focus') && !p.touch) typer.focus({ preventScroll: true });
|
|
1978
2082
|
e.preventDefault();
|
|
1979
2083
|
});
|
|
@@ -1981,7 +2085,7 @@ function rdBind(root) {
|
|
|
1981
2085
|
const p = ptrs.get(e.pointerId); if (!p) return;
|
|
1982
2086
|
const dx = e.clientX - p.x, dy = e.clientY - p.y; p.x = e.clientX; p.y = e.clientY;
|
|
1983
2087
|
if (!p.moved && Math.hypot(p.x - p.x0, p.y - p.y0) > 8) { p.moved = true; clearTimeout(p.lp); }
|
|
1984
|
-
if (!p.moved || p.long) return;
|
|
2088
|
+
if (!p.moved || p.long || p.zoomed) return;
|
|
1985
2089
|
if (p.touch && (ptrs.size > 1 || !RD.drag)) { if (ptrs.size > 1 && e.isPrimary === false) return; rdScroll(dx, dy, rdPos(e)); return; }
|
|
1986
2090
|
const now = performance.now(), pos = rdPos(e);
|
|
1987
2091
|
if (pos && now - p.pt > 22) { p.path.push(pos); p.pt = now; }
|
|
@@ -1989,7 +2093,7 @@ function rdBind(root) {
|
|
|
1989
2093
|
const end = (e) => {
|
|
1990
2094
|
const p = ptrs.get(e.pointerId); if (!p) return;
|
|
1991
2095
|
ptrs.delete(e.pointerId); clearTimeout(p.lp);
|
|
1992
|
-
if (p.long) return;
|
|
2096
|
+
if (p.long || p.zoomed) return;
|
|
1993
2097
|
const pos = rdPos(e);
|
|
1994
2098
|
if (!p.moved) { if (pos && e.type === 'pointerup') click(pos, e); return; }
|
|
1995
2099
|
if (p.touch && (ptrs.size > 0 || !RD.drag)) return;
|
|
@@ -2043,6 +2147,12 @@ function rdBind(root) {
|
|
|
2043
2147
|
rdHint(); break;
|
|
2044
2148
|
}
|
|
2045
2149
|
case 'kbd': typer.focus(); break;
|
|
2150
|
+
case 'zin': rdZoom(RD.z * 1.5); break;
|
|
2151
|
+
case 'zout': rdZoom(RD.z / 1.5); break;
|
|
2152
|
+
case 'zfit': RD.z = 1; rdView(); break;
|
|
2153
|
+
case 'z11': { const im = $('#rdImg'); rdZoom(RD.sw && im.offsetWidth ? RD.sw / im.offsetWidth : 2); break; }
|
|
2154
|
+
case 'save': rdSave(); break;
|
|
2155
|
+
case 'two': RD.two = RD.two === 'zoom' ? 'scroll' : 'zoom'; b.textContent = RD.two === 'zoom' ? '2 dits: zoom' : '2 dits: desplaça'; break;
|
|
2046
2156
|
case 'drag': RD.drag = !RD.drag; b.textContent = RD.drag ? '1 dit: arrossega' : '1 dit: desplaça'; break;
|
|
2047
2157
|
}
|
|
2048
2158
|
});
|
package/web/app/style.css
CHANGED
|
@@ -477,6 +477,14 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
|
|
|
477
477
|
.help-body { display: grid; gap: 8px; padding: 0 0 14px; font-size: 14px; color: var(--fg-muted); }
|
|
478
478
|
.help-body ul { padding-left: 18px; display: grid; gap: 4px; }
|
|
479
479
|
.help-body b { color: var(--fg); font-weight: 600; }
|
|
480
|
+
.help-body .mono { padding: 2px 7px; border-radius: 6px; background: var(--code); color: var(--fg); white-space: nowrap; }
|
|
481
|
+
.help-body .cmdb { margin: 2px 0; padding: 12px 14px; border-radius: 10px; background: var(--code); color: var(--fg); font: 14px/1.4 var(--mono); overflow-x: auto; }
|
|
482
|
+
.help-body .cmdlist { display: grid; gap: 6px; margin: 2px 0; }
|
|
483
|
+
.help-body .cmdlist > div { display: grid; grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); gap: 4px 14px; align-items: baseline; padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--code) 45%, transparent); }
|
|
484
|
+
.help-body .cmdlist .mono { justify-self: start; white-space: normal; overflow-wrap: anywhere; }
|
|
485
|
+
.help-grp { margin: 22px 0 2px; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-muted); }
|
|
486
|
+
.help-grp:first-child { margin-top: 0; }
|
|
487
|
+
@media (max-width: 620px) { .help-body .cmdlist > div { grid-template-columns: 1fr; } }
|
|
480
488
|
.empty .stepper { text-align: left; max-width: 640px; margin: 16px auto 18px; }
|
|
481
489
|
a.kbd { text-decoration: none; color: inherit; }
|
|
482
490
|
|
|
@@ -668,3 +676,11 @@ html.rd-open, html.rd-open body { overflow: hidden; }
|
|
|
668
676
|
.rd-keys[hidden] { display: none; }
|
|
669
677
|
.rd-type { position: fixed; left: 0; top: 0; width: 1px; height: 1px; opacity: 0; border: 0; padding: 0; resize: none; font-size: 16px; pointer-events: none; }
|
|
670
678
|
@media (max-width: 560px) { .rd-bar { padding: 8px 10px; gap: 8px; } .rd-stat, #rdFull { display: none; } .rd-seg button { padding: 9px 11px; } }
|
|
679
|
+
|
|
680
|
+
.rd-stage img { transform-origin: center; will-change: transform; }
|
|
681
|
+
.rd-tools { position: absolute; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 14px; background: rgba(22, 22, 23, .86); border: 1px solid #36363a; backdrop-filter: blur(8px); z-index: 2; max-width: calc(100% - 20px); }
|
|
682
|
+
.rd-tools .rd-btn { padding: 10px 12px; min-width: 40px; }
|
|
683
|
+
.rd-pct { min-width: 56px !important; font-variant-numeric: tabular-nums; }
|
|
684
|
+
.rd-sep { width: 1px; align-self: stretch; background: #36363a; }
|
|
685
|
+
.rd-msg { bottom: 84px; }
|
|
686
|
+
@media (max-width: 560px) { .rd-tools { gap: 4px; padding: 5px; } .rd-tools .rd-btn { padding: 12px 10px; min-width: 44px; font-size: 14px; } .rd-pct { min-width: 52px !important; } }
|
package/web/board.css
CHANGED
|
@@ -190,3 +190,53 @@
|
|
|
190
190
|
.bd-chat header { padding-top: 22px; }
|
|
191
191
|
.bd-chat .cin textarea { font-size: 16px; }
|
|
192
192
|
}
|
|
193
|
+
|
|
194
|
+
/* Tàctil: botons còmodes, sense menú de pressió llarga ni selecció */
|
|
195
|
+
.bd-stage, .bd-svg { -webkit-touch-callout: none; }
|
|
196
|
+
@media (pointer: coarse) {
|
|
197
|
+
.bd .ib, .bd .pct { height: 40px; }
|
|
198
|
+
.bd .ib { width: 40px; }
|
|
199
|
+
.bd .pct { min-width: 52px; font-size: 13px; }
|
|
200
|
+
.bd .tb { width: 44px; height: 44px; }
|
|
201
|
+
.bd .chat-t { height: 44px; }
|
|
202
|
+
.bd-menu { top: 50px; min-width: 250px; }
|
|
203
|
+
.bd-menu button, .bd-menu label { padding: 13px 12px; font-size: 15px; }
|
|
204
|
+
.bd-style .sw-c { width: 34px; height: 34px; }
|
|
205
|
+
.bd-style .seg2 button { height: 36px; }
|
|
206
|
+
.bd-chat .chip-s { height: 38px; line-height: 36px; font-size: 13px; }
|
|
207
|
+
.bd-chat .send { width: 46px; height: 46px; }
|
|
208
|
+
}
|
|
209
|
+
@media (pointer: coarse) and (max-width: 820px) {
|
|
210
|
+
.bd-zoom [data-bd="zin"], .bd-zoom [data-bd="zout"] { display: none; }
|
|
211
|
+
.bd-tools { gap: 0; scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
|
|
212
|
+
.bd .tb { scroll-snap-align: center; }
|
|
213
|
+
}
|
|
214
|
+
@container stage (max-width: 440px) {
|
|
215
|
+
.bd .tb { width: 40px; height: 44px; }
|
|
216
|
+
.bd-zoom { max-width: calc(100% - 128px); }
|
|
217
|
+
}
|
|
218
|
+
@container stage (max-width: 400px) {
|
|
219
|
+
.bd-tools .tb { min-width: 34px; height: 44px; }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Teclat obert: el xat puja per damunt del teclat */
|
|
223
|
+
@media (max-width: 820px) {
|
|
224
|
+
.bd-chat { bottom: var(--kb, 0px); transition: transform .34s var(--bd-ease), bottom .2s, visibility 0s linear .34s; }
|
|
225
|
+
.chat-open .bd-chat { transition: transform .34s var(--bd-ease), bottom .2s, visibility 0s; }
|
|
226
|
+
.bd-chat.dragging { transition: none !important; }
|
|
227
|
+
}
|
|
228
|
+
@container stage (max-width: 720px) {
|
|
229
|
+
.bd-chat { bottom: var(--kb, 0px); height: min(78%, 600px, calc(100% - var(--kb, 0px) - 8px)); }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* Mòbil en horitzontal: poc d'alçada */
|
|
233
|
+
@media (max-height: 480px) and (max-width: 940px) {
|
|
234
|
+
.bd-chat { height: min(92%, 600px); }
|
|
235
|
+
.bd-tools { bottom: calc(6px + env(safe-area-inset-bottom)); left: auto; right: auto; max-width: calc(100% - 20px); }
|
|
236
|
+
.bd-tools { left: 50%; transform: translateX(-50%); }
|
|
237
|
+
.bd .tb { height: 40px; }
|
|
238
|
+
.style-open .bd-style:not([hidden]) { bottom: calc(58px + env(safe-area-inset-bottom)); max-height: calc(100% - 130px); overflow-y: auto; grid-template-columns: repeat(2, 1fr); }
|
|
239
|
+
.bd-help-in { padding: 14px; }
|
|
240
|
+
.bd-empty { display: none; }
|
|
241
|
+
}
|
|
242
|
+
|
package/web/board.js
CHANGED
|
@@ -42,6 +42,7 @@ const uid = () => Math.random().toString(36).slice(2, 10);
|
|
|
42
42
|
const norm = s => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/·/g, '').toLowerCase().trim();
|
|
43
43
|
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
44
44
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
45
|
+
const coarse = matchMedia('(pointer: coarse)').matches;
|
|
45
46
|
|
|
46
47
|
/* ---------- Estat ---------- */
|
|
47
48
|
function fresh() {
|
|
@@ -311,13 +312,13 @@ function drawSel() {
|
|
|
311
312
|
if (list.length) {
|
|
312
313
|
const p = 6 / z;
|
|
313
314
|
if (list.length === 1 && isLinear(list[0])) {
|
|
314
|
-
const [a, b] = ends(list[0]), r = 6 / z;
|
|
315
|
+
const [a, b] = ends(list[0]), r = (coarse ? 11 : 6) / z;
|
|
315
316
|
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
317
|
} else {
|
|
317
318
|
const b = union(list);
|
|
318
319
|
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
320
|
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 H = (coarse ? 17 : 9) / z, bx = b.x - p, by = b.y - p, bw = b.w + p * 2, bh = b.h + p * 2;
|
|
321
322
|
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
323
|
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
|
}
|
|
@@ -395,7 +396,7 @@ function onDown(e) {
|
|
|
395
396
|
if (editing) { endEdit(); if (tool === 'select') return; }
|
|
396
397
|
R.svg.setPointerCapture(e.pointerId);
|
|
397
398
|
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
|
+
if (pointers.size === 2) { abortDrag(); 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
400
|
const [wx, wy] = toWorld(e.clientX, e.clientY);
|
|
400
401
|
const hitG = e.target.closest('[data-id]'), hit = hitG ? byId(hitG.dataset.id) : null;
|
|
401
402
|
const touch = e.pointerType === 'touch';
|
|
@@ -439,6 +440,13 @@ function onDown(e) {
|
|
|
439
440
|
drag = { mode: 'draw', el, last: [wx, wy] };
|
|
440
441
|
}
|
|
441
442
|
}
|
|
443
|
+
function abortDrag() {
|
|
444
|
+
const d = drag; if (!d) return;
|
|
445
|
+
if (d.mode === 'create' || d.mode === 'line' || d.mode === 'draw') { B.els = B.els.filter(x => x !== d.el); nodes.get(d.el.id)?.remove(); nodes.delete(d.el.id); hist.pop(); }
|
|
446
|
+
else if (d.mode === 'move' && d.rec) { Object.entries(d.orig).forEach(([id, o]) => { const el = byId(id); if (el) { el.x = o.x; el.y = o.y; } }); hist.pop(); full(); }
|
|
447
|
+
else if (d.mode === 'resize' && d.rec) { Object.assign(d.el, d.o); hist.pop(); full(); }
|
|
448
|
+
marquee = null; R.stage.classList.remove('panning'); drag = null; R.empty.hidden = !!B.els.length; drawSel();
|
|
449
|
+
}
|
|
442
450
|
function snapshotSel() { const m = {}; sel.forEach(id => { const el = byId(id); if (el) m[id] = { x: el.x, y: el.y }; }); return m; }
|
|
443
451
|
function onMove(e) {
|
|
444
452
|
if (!R) return;
|
|
@@ -559,8 +567,18 @@ function onWheel(e) {
|
|
|
559
567
|
if (!R) return;
|
|
560
568
|
e.preventDefault();
|
|
561
569
|
const r = R.svg.getBoundingClientRect();
|
|
562
|
-
|
|
563
|
-
|
|
570
|
+
const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? r.height : 1, dx = e.deltaX * k, dy = e.deltaY * k;
|
|
571
|
+
if (e.ctrlKey || e.metaKey) zoomAt(B.view.z * Math.exp(-clamp(dy, -40, 40) * .01), e.clientX - r.left, e.clientY - r.top);
|
|
572
|
+
else { B.view.x -= e.shiftKey && !dx ? dy : dx; B.view.y -= e.shiftKey && !dx ? 0 : dy; applyView(); drawSel(); save(); }
|
|
573
|
+
}
|
|
574
|
+
let gz = null;
|
|
575
|
+
function onGesture(e) {
|
|
576
|
+
if (!R) return;
|
|
577
|
+
e.preventDefault();
|
|
578
|
+
const r = R.svg.getBoundingClientRect();
|
|
579
|
+
if (e.type === 'gesturestart') { gz = { z0: B.view.z }; return; }
|
|
580
|
+
if (e.type === 'gestureend') { gz = null; return; }
|
|
581
|
+
if (gz) zoomAt(gz.z0 * e.scale, e.clientX - r.left, e.clientY - r.top);
|
|
564
582
|
}
|
|
565
583
|
|
|
566
584
|
/* ---------- Accions ---------- */
|
|
@@ -1169,6 +1187,7 @@ function mount(root) {
|
|
|
1169
1187
|
if (first) { const r = R.svg.getBoundingClientRect(); B.view = { x: r.width / 2, y: r.height / 2, z: 1 }; }
|
|
1170
1188
|
on(R.svg, 'pointerdown', onDown); on(window, 'pointermove', onMove); on(window, 'pointerup', onUp); on(window, 'pointercancel', onUp);
|
|
1171
1189
|
on(R.svg, 'wheel', onWheel, { passive: false });
|
|
1190
|
+
['gesturestart', 'gesturechange', 'gestureend'].forEach(t => on(R.svg, t, onGesture, { passive: false }));
|
|
1172
1191
|
on(window, 'keydown', onKey); on(window, 'keyup', onKeyUp);
|
|
1173
1192
|
on(root, 'click', onClick);
|
|
1174
1193
|
on(R.editor, 'input', placeEditor); on(R.editor, 'blur', () => setTimeout(endEdit, 0));
|
|
@@ -1180,6 +1199,11 @@ function mount(root) {
|
|
|
1180
1199
|
on(R.root.querySelector('#bdImport'), 'change', e => { const f = e.target.files[0]; if (f) importJSON(f); e.target.value = ''; });
|
|
1181
1200
|
on(document, 'click', e => { if (R && !e.target.closest('.bd-menuwrap')) { const m = R.root.querySelector('#bdMenu'); if (m) m.hidden = true; } });
|
|
1182
1201
|
on(window, 'resize', () => { applyView(); drawSel(); });
|
|
1202
|
+
const vv = window.visualViewport;
|
|
1203
|
+
if (vv) {
|
|
1204
|
+
const kb = () => R && R.root.style.setProperty('--kb', Math.max(0, Math.round(innerHeight - vv.height - vv.offsetTop)) + 'px');
|
|
1205
|
+
on(vv, 'resize', kb); on(vv, 'scroll', kb); kb();
|
|
1206
|
+
}
|
|
1183
1207
|
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(() => {});
|
|
1184
1208
|
chatSwipe(R.chat);
|
|
1185
1209
|
full(); drawChat(); drawBrains(); setTool(tool); openChat(B.opts.chat && !matchMedia('(max-width: 820px)').matches, false, false);
|
package/web/index.html
CHANGED
|
@@ -79,6 +79,15 @@ main .cmd code::-webkit-scrollbar { display: none; }
|
|
|
79
79
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none; transition: none; } }
|
|
80
80
|
.copy.done .ic { display: none; }
|
|
81
81
|
main strong { font-weight: 600; }
|
|
82
|
+
.feat { list-style: none; display: grid; gap: 14px; padding: 0; margin-top: 4px; }
|
|
83
|
+
.feat li { padding-left: 18px; position: relative; text-wrap: pretty; }
|
|
84
|
+
.feat li::before { content: ""; position: absolute; left: 0; top: .72em; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
|
85
|
+
.cmds { display: grid; gap: 8px; margin: 16px 0; }
|
|
86
|
+
.cmds > div { display: grid; grid-template-columns: minmax(0, 15.5rem) minmax(0, 1fr); gap: 4px 16px; align-items: baseline; padding: 10px 14px; border-radius: 12px; background: #e6e1d8; }
|
|
87
|
+
.cmds dt code { background: #f6f3ee; }
|
|
88
|
+
.cmds dd { font-size: .86em; color: var(--fg-muted); }
|
|
89
|
+
@media (max-width: 620px) { .cmds > div { grid-template-columns: 1fr; } }
|
|
90
|
+
|
|
82
91
|
|
|
83
92
|
.footer .page { padding: 40px 0 56px; text-align: center; font-size: 13px; color: var(--fg-muted); }
|
|
84
93
|
.footer a { color: var(--fg); text-decoration: underline; text-decoration-color: var(--red); text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
|
@@ -117,16 +126,66 @@ main strong { font-weight: 600; }
|
|
|
117
126
|
|
|
118
127
|
<section aria-labelledby="que">
|
|
119
128
|
<h2 id="que">Què hi pots fer</h2>
|
|
120
|
-
<ul class="
|
|
121
|
-
<li><strong>Sessions
|
|
122
|
-
<li><strong>Aprovacions
|
|
123
|
-
<li><strong>
|
|
124
|
-
<li><strong>
|
|
125
|
-
<li><strong>
|
|
126
|
-
<li><strong>
|
|
129
|
+
<ul class="feat">
|
|
130
|
+
<li><strong>Sessions en directe.</strong> Claude Code i Codex a qualsevol dels teus ordinadors: tria l’agent, la carpeta i el nivell de permisos, i la resposta et va arribant mentre treballa, amb les eines que fa servir.</li>
|
|
131
|
+
<li><strong>Aprovacions.</strong> Abans d’editar, executar o clicar res, te’n demana permís amb el motiu, i ho aproves o ho rebutges amb un toc. Tot queda registrat a Activitat.</li>
|
|
132
|
+
<li><strong>Avisos al mòbil.</strong> Una notificació quan un agent et demana permís o acaba la feina. Arriba buida: el detall el demana el mòbil directament a l’ordinador.</li>
|
|
133
|
+
<li><strong>Feines.</strong> Que es facin soles: ara, més tard o cada dia, i encadenades («quan Claude Code acabi, que Codex ho revisi»).</li>
|
|
134
|
+
<li><strong>Memòria compartida.</strong> El que recorda un agent, ho sap l’altre. Són fitxers markdown al teu ordinador.</li>
|
|
135
|
+
<li><strong>Ordinadors i servidors.</strong> El teu ordinador o un servidor (VPS), tots a la mateixa llista. Els afegeixes escrivint <code>usuari@servidor</code>.</li>
|
|
136
|
+
<li><strong>Pantalla remota.</strong> Mira i controla l’escriptori de l’ordinador des del mòbil, amb zoom amb dos dits i captures. Més avall.</li>
|
|
137
|
+
<li><strong>Navegador i escriptori per als agents.</strong> Poden mirar webs i apps; per actuar, sempre et demanen permís amb una captura.</li>
|
|
138
|
+
<li><strong>Telegram i veu.</strong> Parla amb el teu bot o amb notes de veu, aprova amb un botó, i escolta les respostes (ElevenLabs o OpenAI). Les claus es queden a l’ordinador.</li>
|
|
139
|
+
<li><strong>Git i GitHub.</strong> Clona un repositori, mira els canvis, fes commit i crea un PR des del mòbil, amb el <code>git</code> i el <code>gh</code> de l’ordinador.</li>
|
|
140
|
+
<li><strong>Models d’Ollama i xat local.</strong> Descarrega i fes servir models gratuïts a l’ordinador, o un model petit dins del navegador del mòbil.</li>
|
|
141
|
+
<li><strong>Pissarra.</strong> Un llenç per fer diagrames, i en Nio te’ls dibuixa si li ho expliques. Més avall.</li>
|
|
142
|
+
<li><strong>Com una app.</strong> Afegeix nio a la pantalla d’inici del mòbil; llisca als costats per canviar de pàgina i <code>Ctrl K</code> obre el buscador a l’ordinador.</li>
|
|
127
143
|
</ul>
|
|
128
144
|
</section>
|
|
129
145
|
|
|
146
|
+
<section aria-labelledby="cmds">
|
|
147
|
+
<h2 id="cmds">Comandes amb «/»</h2>
|
|
148
|
+
<p>Al missatge d’una sessió, escriu <code>/</code> i surten els suggeriments. Les de nio funcionen amb tots els agents; les de l’agent (comandes i skills) se li envien tal qual.</p>
|
|
149
|
+
<dl class="cmds">
|
|
150
|
+
<div><dt><code>/mode</code></dt><dd>Canvia els permisos: <code>l</code> llegir, <code>p</code> preguntar, <code>t</code> tot</dd></div>
|
|
151
|
+
<div><dt><code>/model</code></dt><dd>Canvia de model</dd></div>
|
|
152
|
+
<div><dt><code>/stop</code></dt><dd>Atura l’agent ara mateix</dd></div>
|
|
153
|
+
<div><dt><code>/nova</code></dt><dd>Nova sessió amb el mateix agent i carpeta</dd></div>
|
|
154
|
+
<div><dt><code>/carpeta</code></dt><dd>Canvia la carpeta de la sessió</dd></div>
|
|
155
|
+
<div><dt><code>/git</code></dt><dd>Veure canvis, fer commit i crear un PR</dd></div>
|
|
156
|
+
<div><dt><code>/nom</code></dt><dd>Posa nom a la sessió</dd></div>
|
|
157
|
+
<div><dt><code>/opcions</code></dt><dd>Opcions de la sessió</dd></div>
|
|
158
|
+
<div><dt><code>/ajuda</code></dt><dd>Llista de comandes</dd></div>
|
|
159
|
+
</dl>
|
|
160
|
+
</section>
|
|
161
|
+
|
|
162
|
+
<section aria-labelledby="screen">
|
|
163
|
+
<h2 id="screen">La pantalla remota</h2>
|
|
164
|
+
<p>Obre l’escriptori d’un dels teus ordinadors a pantalla completa, a la mateixa amplada que el mòbil, com un escriptori remot. Al mode <strong>Mirar</strong> només veus què fa l’agent; al mode <strong>Controlar</strong> pots clicar, fer doble clic, clic dret, arrossegar, desplaçar i escriure.</p>
|
|
165
|
+
<dl class="cmds">
|
|
166
|
+
<div><dt><code>− 100% +</code></dt><dd>Allunya, apropa o torna a enquadrar la imatge</dd></div>
|
|
167
|
+
<div><dt><code>1:1</code></dt><dd>Mida real, per llegir text petit</dd></div>
|
|
168
|
+
<div><dt><code>Desa captura</code></dt><dd>Baixa el fotograma actual com a imatge</dd></div>
|
|
169
|
+
<div><dt><code>Pantalla completa</code></dt><dd>A l’ordinador, amaga tota la resta</dd></div>
|
|
170
|
+
</dl>
|
|
171
|
+
<p><strong>Zoom amb dos dits.</strong> Pessiga per apropar i arrossega amb dos dits per moure’t; al mode Mirar, doble toc alterna entre enquadrar i apropar. Al mode Controlar, el botó «2 dits» tria si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o fes servir <code>Ctrl</code> i la roda. Cal activar «Apps de l’escriptori» a l’ordinador, i al Mac donar al terminal els permisos de Gravació de pantalla i Accessibilitat.</p>
|
|
172
|
+
</section>
|
|
173
|
+
|
|
174
|
+
<section aria-labelledby="board">
|
|
175
|
+
<h2 id="board">La pissarra</h2>
|
|
176
|
+
<p>Un llenç infinit per fer fluxos, mapes mentals i organigrames, al mòbil i a l’ordinador. Rectangles, el·lipses, rombes, fletxes que s’enganxen a les figures, línies, llapis, text i goma. Desfés i refés, quadrícula, i exportació a PNG, SVG o JSON.</p>
|
|
177
|
+
<p><strong>Zoom amb dos dits.</strong> Al mòbil, pessiga sobre el llenç per apropar o allunyar, i mou-lo amb els dos dits alhora. A l’ordinador, pessiga al trackpad o fes servir <code>Ctrl</code> i la roda del ratolí.</p>
|
|
178
|
+
<p><strong>Demana-ho a en Nio.</strong> Obre el xat de la pissarra i escriu què vols:</p>
|
|
179
|
+
<dl class="cmds">
|
|
180
|
+
<div><dt><code>Flux: A -> B -> C</code></dt><dd>Una fletxa per línia</dd></div>
|
|
181
|
+
<div><dt><code>Mapa mental de X: a, b, c</code></dt><dd>Tema central amb branques</dd></div>
|
|
182
|
+
<div><dt><code>Organigrama: Direcció > Finances</code></dt><dd>Qui depèn de qui</dd></div>
|
|
183
|
+
<div><dt><code>Connecta Banc amb ERP</code></dt><dd>Uneix dues figures</dd></div>
|
|
184
|
+
<div><dt><code>Pinta Banc de verd</code></dt><dd>Canvia el color</dd></div>
|
|
185
|
+
<div><dt><code>Plantilla d’aprovació</code></dt><dd>Un diagrama de partida</dd></div>
|
|
186
|
+
</dl>
|
|
187
|
+
</section>
|
|
188
|
+
|
|
130
189
|
<section aria-labelledby="teu">
|
|
131
190
|
<h2 id="teu">Tot és teu</h2>
|
|
132
191
|
<p>Fas servir els teus agents i les teves claus (Anthropic, OpenAI, ElevenLabs, el bot de Telegram). Les converses, la memòria i les claus es queden als teus ordinadors. nio.cat només serveix l’app i guarda, signada, l’adreça on trobar cada ordinador. Més detalls a <a href="/privadesa">privadesa</a>.</p>
|
package/web/sw.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
|
|
2
|
-
const SHELL = 'nio-shell-
|
|
2
|
+
const SHELL = 'nio-shell-v25';
|
|
3
3
|
const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
|
|
4
4
|
|
|
5
5
|
self.addEventListener('install', (e) => {
|