From c00966fbd7c2c40e4d82ebd9381af2cacbd9be2c Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 10:50:52 +0300 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20=D0=BE=D1=81=D0=B2=D0=BE=D0=B1?= =?UTF-8?q?=D0=BE=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D0=BF=D0=B0=D0=BC=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BA=D1=80=D0=B8=D0=BD=D1=88=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B8=D0=B3=D0=B4?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B5=20=D0=BD=D1=83=D0=B6=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Картинки не удалялись никогда — даже после очистки и ухода из истории отмены. Программа живёт фоном неделями, а кадр 4K весит десятки мегабайт: перед новым скриншотом освобождаем картинки, на которые не ссылаются ни холст, ни история. Co-Authored-By: Claude Opus 5.5 --- src/core/history.js | 9 +++++++++ src/core/images.js | 11 +++++++++++ src/ui/screenshot.js | 6 ++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/core/history.js b/src/core/history.js index 0969eb4..254b567 100644 --- a/src/core/history.js +++ b/src/core/history.js @@ -25,6 +25,15 @@ function restore(json) { revision++; } +// Id картинок, на которые ссылаются холст и вся история отмены/повтора. +export function referencedImageIds() { + const ids = new Set(); + for (const json of [committed, ...undoStack, ...redoStack]) { + for (const m of json.matchAll(/"imageId":"([^"]+)"/g)) ids.add(m[1]); + } + return ids; +} + // Возвращают true, если состояние изменилось. export function undo() { if (!undoStack.length) return false; diff --git a/src/core/images.js b/src/core/images.js index f1647c5..c56519c 100644 --- a/src/core/images.js +++ b/src/core/images.js @@ -10,3 +10,14 @@ export function addImage(bitmap) { } export const getImage = (id) => images.get(id); +export const imageCount = () => images.size; + +// Программа живёт фоном неделями, а кадр 4K весит десятки мегабайт: картинки, на которые +// не ссылается ни холст, ни история отмены, освобождаем. +export function pruneImages(liveIds) { + for (const [id, bitmap] of images) { + if (liveIds.has(id)) continue; + bitmap.close?.(); + images.delete(id); + } +} diff --git a/src/ui/screenshot.js b/src/ui/screenshot.js index acabed5..ff5f3c9 100644 --- a/src/ui/screenshot.js +++ b/src/ui/screenshot.js @@ -1,8 +1,8 @@ // Скриншот области: снимок экрана под холстом → выделение мышью → картинка на холсте. import { state } from '../core/state.js'; -import { addImage } from '../core/images.js'; +import { addImage, imageCount, pruneImages } from '../core/images.js'; import { createImageShape } from '../core/shapes.js'; -import { commit } from '../core/history.js'; +import { commit, referencedImageIds } from '../core/history.js'; import { t } from '../i18n/index.js'; import { api } from './api.js'; import { finishEdit } from './editor.js'; @@ -119,6 +119,8 @@ async function place(frame, rect) { // Картинка — в пикселях экрана (чётко на любом масштабе), фигура — в CSS-пикселях. const crop = await createImageBitmap(frame, Math.round(rect.x * sx), Math.round(rect.y * sy), Math.round(rect.w * sx), Math.round(rect.h * sy)); + // Перед новой картинкой освобождаем те, что уже нигде не нужны (удалены и ушли из истории). + if (imageCount()) pruneImages(referencedImageIds()); const shape = createImageShape(addImage(crop), rect.x, rect.y, rect.w, rect.h); state.shapes.push(shape); commit(); From 035c8f52429a29ab2446eb0d08e2478b5d8b549e Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 10:50:52 +0300 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8,=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Если в файле не было installId, он генерировался заново на каждом запуске и не сохранялся — хаб считал бы каждый запуск новой установкой. Тесты на значения по умолчанию и миграцию хоткея. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/settings.rs | 53 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 6aee836..e488baa 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -16,7 +16,7 @@ pub const LANGS: [&str; 11] = [ "ru", "en", "de", "es", "fr", "pt", "zh", "ja", "ko", "ar", "hi", ]; -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Settings { /// `None` — язык системы. @@ -73,12 +73,15 @@ impl Store { .as_ref() .and_then(|p| fs::read_to_string(p).ok()) .and_then(|text| serde_json::from_str::(&text).ok()); - let fresh = loaded.is_none(); + let settings = loaded.clone().unwrap_or_default().sanitize(); + // Сохраняем и новый файл, и исправленный при чтении: иначе, например, сгенерированный + // install_id менялся бы на каждом запуске, и хаб считал бы каждый запуск новой установкой. + let changed = loaded.as_ref() != Some(&settings); let store = Self { path, - data: Mutex::new(loaded.unwrap_or_default().sanitize()), + data: Mutex::new(settings), }; - if fresh { + if changed { store.save(); } store @@ -105,3 +108,45 @@ impl Store { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_or_broken_values_get_defaults() { + let s: Settings = serde_json::from_str(r#"{"lang":"xx","hotkey":" "}"#).unwrap(); + let s = s.sanitize(); + assert_eq!(s.lang, None); + assert_eq!(s.hotkey, DEFAULT_HOTKEY); + assert!(uuid::Uuid::parse_str(&s.install_id).is_ok()); + assert!(s.auto_update); + assert!(!s.autostart_default_applied); + } + + #[test] + fn legacy_default_hotkey_is_migrated_but_custom_kept() { + let legacy = Settings { + hotkey: LEGACY_HOTKEY.into(), + ..Settings::default() + } + .sanitize(); + assert_eq!(legacy.hotkey, DEFAULT_HOTKEY); + let custom = Settings { + hotkey: "Control+Shift+K".into(), + ..Settings::default() + } + .sanitize(); + assert_eq!(custom.hotkey, "Control+Shift+K"); + } + + #[test] + fn valid_settings_stay_unchanged() { + let s = Settings::default().sanitize(); + assert_eq!( + s.clone().sanitize(), + s, + "повторная проверка ничего не меняет — файл не перезаписывается зря" + ); + } +} From e6f8076ec3a41f397c9f6f7a5f79f349e382c561 Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 10:50:52 +0300 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B0=D1=82=D1=8C,=20=D0=B5?= =?UTF-8?q?=D1=81=D0=BB=D0=B8=20=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=85=D0=BE=D1=82=D0=BA=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BD=D1=8F=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Без хоткея холст не открыть ничем, кроме трея, а предупреждение было только в скрытых настройках. Теперь холст показывается и при автозапуске, внизу — подсказка сменить комбинацию. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/lib.rs | 11 +++++------ src/main.js | 10 ++++++++-- src/ui/settings.js | 1 + 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0b526fa..8c54a67 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -161,13 +161,12 @@ pub fn run() { app.manage(store); overlay::create(handle)?; app.manage(tray::create(handle)?); - // Занятый хоткей не должен мешать запуску: пользователь увидит это в настройках. - if hotkey::replace(handle, &app.state::(), None, &accelerator).is_err() - { - eprintln!("Не удалось зарегистрировать хоткей {accelerator}"); - } + // Занятый хоткей не мешает запуску, но без него холст не открыть ничем, кроме трея — + // поэтому тогда показываем холст и при автозапуске: интерфейс скажет, что делать. + let hotkey_ok = + hotkey::replace(handle, &app.state::(), None, &accelerator).is_ok(); enable_autostart_once(handle); - if !std::env::args().any(|a| a == HIDDEN_ARG) { + if !hotkey_ok || !std::env::args().any(|a| a == HIDDEN_ARG) { overlay::show(handle); } Ok(()) diff --git a/src/main.js b/src/main.js index 3633d0c..0895c2a 100644 --- a/src/main.js +++ b/src/main.js @@ -3,7 +3,7 @@ import { editText } from './ui/editor.js'; import { setMode, toggleMode } from './ui/mode.js'; import { initToolbar, relabelToolbar } from './ui/toolbar.js'; import { initHelp, renderHelp } from './ui/help.js'; -import { setPlatform } from './ui/keys.js'; +import { setPlatform, formatKeys } from './ui/keys.js'; import { initKeyboard } from './ui/keyboard.js'; import { initPointer } from './ui/pointer.js'; import { initSettings } from './ui/settings.js'; @@ -12,6 +12,8 @@ import { selectTool } from './ui/toolbar.js'; import { state } from './core/state.js'; import { createTools } from './tools/index.js'; import { api } from './ui/api.js'; +import { showToast } from './ui/toast.js'; +import { t } from './i18n/index.js'; const tools = createTools({ render, edit: editText }); const info = await api.getSettings(); @@ -25,7 +27,11 @@ initScreenshot({ placed: (shape) => { selectTool('select'); state.sel = shape.id initKeyboard(tools); initPointer(tools); addEventListener('resize', resize); -api.on('overlay:show', () => setMode('draw')); +api.on('overlay:show', () => { + setMode('draw'); + // Глобальный хоткей занят другой программой — без подсказки не понять, почему он не работает. + if (!info.hotkeyOk) showToast(`${formatKeys(info.hotkey)} — ${t('settings.hotkey.taken')}`); +}); api.on('overlay:toggle', toggleMode); // Зажат Alt: мышь уходит окнам под холстом, рисунок полупрозрачный. api.on('overlay:peek', (held) => document.body.classList.toggle('peek', held)); diff --git a/src/ui/settings.js b/src/ui/settings.js index 2c4c5aa..a83d7a1 100644 --- a/src/ui/settings.js +++ b/src/ui/settings.js @@ -63,6 +63,7 @@ hotkeyButton.addEventListener('keydown', async (e) => { try { await api.setHotkey(combo.accel); info.hotkey = combo.accel; + info.hotkeyOk = true; showHotkeyError(null); onRelabel(); } catch (code) { From ef61afc80ef2a99900660a6b4aec58caf18856df Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 10:50:52 +0300 Subject: [PATCH 04/12] =?UTF-8?q?test:=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D1=82=D0=BE=D0=B9=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=8F=D0=B4=D1=80=D0=B0=20=D0=BD=D0=B0=20node:t?= =?UTF-8?q?est?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown, история отмены, геометрия с поворотом, привязка стрелок, освобождение картинок — без DOM, Tauri и зависимостей (npm test). Запускаются в CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 +- CLAUDE.md | 4 +- README.md | 1 + README.ru.md | 2 +- package.json | 2 + tests/core.test.js | 122 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 tests/core.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55dad78..c8583fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# Проверки на каждый пуш и PR: Rust (clippy, тесты) и интерфейс (синтаксис, импорты, словари). +# Проверки на каждый пуш и PR: Rust (clippy, тесты) и интерфейс (синтаксис, импорты, словари, тесты ядра). name: CI on: @@ -37,6 +37,7 @@ jobs: run: | npm ci --no-audit --no-fund npm run check + npm test - name: Linux system libraries run: | diff --git a/CLAUDE.md b/CLAUDE.md index ca0069c..37cac01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ - **O**: новый инструмент добавляется новым модулем/записью в реестре инструментов, а не ещё одной веткой `if/switch` в общем коде. - **L**: все инструменты имеют единый интерфейс (`down`, `move`, `up` + метаданные `id`, `label`, `icon`/`iconPath`); подменяемы без правок вызывающего кода. Рисуют фигуры не инструменты, а `ui/canvas.js`. - **I**: модуль получает только то, что ему нужно (узкие аргументы/колбэки), а не весь `state`. -- **D**: логика зависит от маленьких интерфейсов, а не от Tauri/DOM напрямую. Доступ к ОС — только через `src/ui/api.js` (команды и события Tauri). Чистая логика (геометрия, история, фигуры) не трогает DOM и Tauri и тестируется отдельно. +- **D**: логика зависит от маленьких интерфейсов, а не от Tauri/DOM напрямую. Доступ к ОС — только через `src/ui/api.js` (команды и события Tauri). Чистая логика (геометрия, история, фигуры) не трогает DOM и Tauri и покрыта тестами (`tests/`, `node:test`, без зависимостей). ### Зависимости между слоями (строго вниз, без циклов) ``` @@ -83,5 +83,5 @@ src/i18n/ словари; используется только из ui ## Git и процесс - Коммиты небольшие и атомарные, формат `feat: ...`, `fix: ...`, `refactor: ...`, `docs: ...` (описание на русском, как в истории). - Рефакторинг отдельно от изменения поведения. -- Перед коммитом: `npm run check`, `cargo fmt`, `cargo clippy --all-targets -- -D warnings` и `cargo test` чистые; `npm start` запускается, основные сценарии (рисование, сквозной режим, хоткей, настройки) проверены вручную. Это же гоняет CI. +- Перед коммитом: `npm run check`, `npm test`, `cargo fmt`, `cargo clippy --all-targets -- -D warnings` и `cargo test` чистые; `npm start` запускается, основные сценарии (рисование, сквозной режим, хоткей, настройки) проверены вручную. Это же гоняет CI. - Основная ветка — `main`. diff --git a/README.md b/README.md index dcc1fe1..6c76d0a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ npm install npm start # run in development npm run build # installer for the current system npm run check # interface checks +npm test # core logic tests ``` Releases: `npm version patch && git push --follow-tags` — GitHub Actions builds the installers for all three systems into a draft release. diff --git a/README.ru.md b/README.ru.md index 5410118..f231c23 100644 --- a/README.ru.md +++ b/README.ru.md @@ -77,7 +77,7 @@ npm version patch Секреты репозитория: `TAURI_SIGNING_PRIVATE_KEY` и `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — ключ подписи обновлений (публичная половина — в `src-tauri/tauri.conf.json`). -Проверки локально: `npm run check` (интерфейс), `cargo clippy --all-targets -- -D warnings` и `cargo test` в `src-tauri/`. +Проверки локально: `npm run check` (интерфейс), `npm test` (тесты ядра), `cargo clippy --all-targets -- -D warnings` и `cargo test` в `src-tauri/`. ## Структура diff --git a/package.json b/package.json index 7a2292d..8de6769 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,12 @@ "license": "Apache-2.0", "author": "ProAnimaStudio (Ian Panaev)", "private": true, + "type": "module", "scripts": { "start": "tauri dev", "build": "tauri build", "check": "node scripts/check.mjs", + "test": "node --test tests/*.test.js", "version": "node scripts/sync-version.mjs && git add src-tauri/Cargo.toml src-tauri/Cargo.lock", "tauri": "tauri" }, diff --git a/tests/core.test.js b/tests/core.test.js new file mode 100644 index 0000000..72657cb --- /dev/null +++ b/tests/core.test.js @@ -0,0 +1,122 @@ +// Тесты чистой логики (src/core): без DOM, Tauri и зависимостей — `npm test`. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { state } from '../src/core/state.js'; +import { parseLines, parseInline } from '../src/core/markdown.js'; +import { rectEdge, distToSegment } from '../src/core/geometry.js'; +import { commit, undo, redo, version, referencedImageIds } from '../src/core/history.js'; +import { addImage, getImage, imageCount, pruneImages } from '../src/core/images.js'; +import { + setTextMeasurer, createShape, createImageShape, hit, bounds, resolveLine, removeShape, + attachAt, toLocal, toWorld, handles, textSize, +} from '../src/core/shapes.js'; + +// Ширина текста — 10 px на символ: детерминированно, без canvas. +setTextMeasurer((_font, text) => text.length * 10); + +const close = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) < eps, `${a} ≈ ${b}`); +const reset = () => { state.shapes = []; state.sel = null; }; + +test('markdown: заголовки, списки и стили', () => { + const [h1, h2, li, ol, plain] = parseLines('# Заголовок\n## План **срочно**\n- пункт `код`\n3) третий\nобычный *курсив*'); + assert.equal(h1.scale, 1.6); + assert.equal(h1.bold, true); + assert.deepEqual(h2.runs, [{ text: 'План ' }, { text: 'срочно', bold: true }]); + assert.equal(li.prefix, '• '); + assert.deepEqual(li.runs[1], { text: 'код', code: true }); + assert.equal(ol.prefix, '3. '); + assert.deepEqual(plain.runs[1], { text: 'курсив', italic: true }); +}); + +test('markdown: арифметика и одиночные звёздочки — не курсив', () => { + assert.deepEqual(parseInline('2 * 3 * 4 = 24'), [{ text: '2 * 3 * 4 = 24' }]); + assert.deepEqual(parseInline('цена 5*'), [{ text: 'цена 5*' }]); + assert.deepEqual(parseInline('~~старое~~ новое'), [{ text: 'старое', strike: true }, { text: ' новое' }]); +}); + +test('markdown: пустые строки сохраняют высоту текста', () => { + const s = { size: 20, text: 'a\n\nb' }; + assert.equal(parseLines(s.text).length, 3); + close(textSize(s).h, 3 * 20 * 1.25); +}); + +test('геометрия: расстояние до отрезка и точка на границе', () => { + close(distToSegment(5, 5, 0, 0, 10, 0), 5); + close(distToSegment(-3, 4, 0, 0, 10, 0), 5); + const edge = rectEdge({ x: 0, y: 0, w: 100, h: 50 }, 300, 25); + close(edge.x, 100); + close(edge.y, 25); +}); + +test('история: отмена, повтор и версия', () => { + reset(); + commit(); + const v0 = version(); + state.shapes.push(createShape('rect', 0, 0)); + commit(); + assert.equal(version(), v0 + 1); + commit(); // без изменений — версия не растёт + assert.equal(version(), v0 + 1); + assert.equal(undo(), true); + assert.equal(state.shapes.length, 0); + assert.equal(redo(), true); + assert.equal(state.shapes.length, 1); + assert.equal(redo(), false); +}); + +test('поворот: перевод точки туда и обратно', () => { + const s = { x: 0, y: 0, w: 100, h: 50, rotation: Math.PI / 2 }; + const w = toWorld(s, 100, 50); + const back = toLocal(s, w.x, w.y); + close(back.x, 100); + close(back.y, 50); +}); + +test('скриншот: попадание и рамка учитывают поворот', () => { + reset(); + const img = createImageShape('img-test', 0, 0, 100, 20); + img.rotation = Math.PI / 2; // вертикальная полоса 20×100 с центром (50, 10) + assert.equal(hit(img, 50, 50), true); + assert.equal(hit(img, 90, 10), false); + const b = bounds(img); + close(b.w, 20); + close(b.h, 100); + assert.ok(handles(img).rotate, 'у скриншота есть ручка поворота'); + assert.equal(handles(createShape('rect', 0, 0)).rotate, null, 'у прямоугольника — нет'); +}); + +test('стрелка цепляется к блоку и отцепляется при его удалении', () => { + reset(); + const node = createShape('node', 0, 0); + Object.assign(node, { w: 100, h: 50 }); + state.shapes.push(node); + assert.equal(attachAt(50, 25), node); + const arrow = createShape('arrow', 0, 0); + Object.assign(arrow, { x2: 300, y2: 25, from: node.id }); + state.shapes.push(arrow); + const line = resolveLine(arrow); + close(line.x1, 100); // начало — на правой границе блока, а не в центре + close(line.y1, 25); + removeShape(node); + assert.equal(arrow.from, null); + close(arrow.x1, 100); // конец остаётся там, где был +}); + +test('картинки: освобождаются только те, на которые никто не ссылается', () => { + reset(); + const closed = []; + const bitmap = (name) => ({ close: () => closed.push(name) }); + const kept = addImage(bitmap('kept')); + const dropped = addImage(bitmap('dropped')); + state.shapes.push(createImageShape(kept, 0, 0, 10, 10)); + commit(); + const live = referencedImageIds(); + assert.equal(live.has(kept), true); + assert.equal(live.has(dropped), false); + pruneImages(live); + assert.ok(getImage(kept)); + assert.equal(getImage(dropped), undefined); + assert.deepEqual(closed, ['dropped']); + assert.equal(imageCount(), 1); +}); From 16da4095d25a823f681dd8205ab0a8ff78ffb6dd Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 10:58:33 +0300 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20=D1=80=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B8=D0=B3=D1=83=D1=80?= =?UTF-8?q?=20=E2=80=94=20=D0=B2=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20?= =?UTF-8?q?=D1=81=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui/shapes-draw.js рисует в любой 2D-контекст: и на экран, и в картинку для экспорта. canvas.js отвечает только за экранный холст: кадры, выделение, лазер. Co-Authored-By: Claude Opus 5.5 --- src/ui/canvas.js | 131 ++---------------------------------------- src/ui/shapes-draw.js | 127 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 127 deletions(-) create mode 100644 src/ui/shapes-draw.js diff --git a/src/ui/canvas.js b/src/ui/canvas.js index 0de5135..a2f4b1e 100644 --- a/src/ui/canvas.js +++ b/src/ui/canvas.js @@ -1,6 +1,6 @@ import { state } from '../core/state.js'; -import { selected, isBox, canRotate, resolveLine, bounds, layoutText, setTextMeasurer, ROTATE_HANDLE_OFFSET, CODE_PAD } from '../core/shapes.js'; -import { getImage } from '../core/images.js'; +import { selected, isBox, canRotate, bounds, setTextMeasurer, ROTATE_HANDLE_OFFSET } from '../core/shapes.js'; +import { drawShape, enterShape } from './shapes-draw.js'; export const canvas = document.querySelector('#board'); const ctx = canvas.getContext('2d'); @@ -11,129 +11,6 @@ setTextMeasurer((font, line) => { return ctx.measureText(line).width; }); -function drawArrowHead(x1, y1, x2, y2, width) { - const ang = Math.atan2(y2 - y1, x2 - x1); - const len = 12 + width * 2.5; - ctx.beginPath(); - ctx.moveTo(x2, y2); - ctx.lineTo(x2 - len * Math.cos(ang - 0.4), y2 - len * Math.sin(ang - 0.4)); - ctx.lineTo(x2 - len * Math.cos(ang + 0.4), y2 - len * Math.sin(ang + 0.4)); - ctx.closePath(); - ctx.fill(); -} - -// Markdown-текст: цвет берётся из ctx.fillStyle; свободный текст обводится для читаемости на любом фоне. -function drawText(s, x, y, align) { - const layout = layoutText(s); - const fill = ctx.fillStyle; - const outline = s.type === 'text' ? (s.color === '#111111' ? 'rgba(255,255,255,.7)' : 'rgba(0,0,0,.55)') : null; - ctx.textAlign = 'left'; - ctx.textBaseline = 'top'; - let ly = y; - for (const line of layout.lines) { - // По центру — весь текст как целое; строки внутри выровнены по левому краю (так ровнее списки). - let lx = align === 'center' ? x - layout.w / 2 : x; - for (const run of line.runs) { - const pad = run.code ? CODE_PAD : 0; - if (run.code) { - ctx.fillStyle = 'rgba(127,127,127,.3)'; - ctx.beginPath(); - ctx.roundRect(lx, ly - 1, run.w, line.size * 1.15, 4); - ctx.fill(); - ctx.fillStyle = fill; - } - ctx.font = run.font; - if (outline) { - ctx.lineWidth = 4; - ctx.strokeStyle = outline; - ctx.strokeText(run.text, lx + pad, ly); - } - ctx.fillText(run.text, lx + pad, ly); - if (run.strike) ctx.fillRect(lx + pad, ly + line.size * 0.55, run.w - pad * 2, Math.max(1, line.size / 14)); - lx += run.w; - } - ly += line.h; - } -} - -function drawPen(s) { - const p = s.points; - ctx.beginPath(); - ctx.moveTo(p[0].x, p[0].y); - if (p.length === 1) ctx.lineTo(p[0].x + 0.01, p[0].y); - for (let i = 1; i < p.length - 1; i++) { - ctx.quadraticCurveTo(p[i].x, p[i].y, (p[i].x + p[i + 1].x) / 2, (p[i].y + p[i + 1].y) / 2); - } - if (p.length > 1) ctx.lineTo(p[p.length - 1].x, p[p.length - 1].y); - ctx.stroke(); -} - -function drawShape(s) { - ctx.save(); - ctx.strokeStyle = s.color; - ctx.fillStyle = s.color; - ctx.lineWidth = s.width; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - switch (s.type) { - case 'pen': - drawPen(s); - break; - case 'line': - case 'arrow': { - const l = resolveLine(s); - ctx.beginPath(); - ctx.moveTo(l.x1, l.y1); - ctx.lineTo(l.x2, l.y2); - ctx.stroke(); - if (s.type === 'arrow') drawArrowHead(l.x1, l.y1, l.x2, l.y2, s.width); - break; - } - case 'rect': - ctx.strokeRect(s.x, s.y, s.w, s.h); - break; - case 'ellipse': - ctx.beginPath(); - ctx.ellipse(s.x + s.w / 2, s.y + s.h / 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2); - ctx.stroke(); - break; - case 'node': - ctx.beginPath(); - ctx.roundRect(s.x, s.y, s.w, s.h, 12); - ctx.fillStyle = 'rgba(20,20,24,.9)'; - ctx.fill(); - ctx.stroke(); - if (s !== state.editing) { - ctx.fillStyle = '#fff'; - drawText(s, s.x + s.w / 2, s.y + (s.h - layoutText(s).h) / 2, 'center'); - } - break; - case 'text': - if (s !== state.editing) drawText(s, s.x, s.y, 'left'); - break; - case 'image': - drawImageShape(s); - break; - default: - } - ctx.restore(); -} - -// Начало координат — в центре фигуры, оси повёрнуты вместе с ней. -function enterShape(s) { - ctx.translate(s.x + s.w / 2, s.y + s.h / 2); - ctx.rotate(s.rotation || 0); -} - -function drawImageShape(s) { - const image = getImage(s.imageId); - if (!image) return; - enterShape(s); - ctx.shadowColor = 'rgba(0,0,0,.45)'; - ctx.shadowBlur = 14; - ctx.drawImage(image, -s.w / 2, -s.h / 2, s.w, s.h); -} - function drawSelection(s) { ctx.save(); ctx.strokeStyle = '#0a84ff'; @@ -146,7 +23,7 @@ function drawSelection(s) { return; } // Рамка и ручки — в системе фигуры: совпадают с handles() из core/shapes.js. - enterShape(s); + enterShape(ctx, s); const hw = s.w / 2; const hh = s.h / 2; ctx.strokeRect(-hw - 6, -hh - 6, s.w + 12, s.h + 12); @@ -195,7 +72,7 @@ function drawLaser(now) { function paint() { ctx.clearRect(0, 0, canvas.width, canvas.height); - state.shapes.forEach(drawShape); + for (const s of state.shapes) drawShape(ctx, s, state.editing); const sel = selected(); if (sel && state.tool === 'select' && sel !== state.editing) drawSelection(sel); if (state.laser.points.length) { diff --git a/src/ui/shapes-draw.js b/src/ui/shapes-draw.js new file mode 100644 index 0000000..1528be0 --- /dev/null +++ b/src/ui/shapes-draw.js @@ -0,0 +1,127 @@ +// Рисование фигур в любой 2D-контекст: экранный холст (canvas.js) и картинку для экспорта (export.js). +import { resolveLine, layoutText, CODE_PAD } from '../core/shapes.js'; +import { getImage } from '../core/images.js'; + +function drawArrowHead(c, x1, y1, x2, y2, width) { + const ang = Math.atan2(y2 - y1, x2 - x1); + const len = 12 + width * 2.5; + c.beginPath(); + c.moveTo(x2, y2); + c.lineTo(x2 - len * Math.cos(ang - 0.4), y2 - len * Math.sin(ang - 0.4)); + c.lineTo(x2 - len * Math.cos(ang + 0.4), y2 - len * Math.sin(ang + 0.4)); + c.closePath(); + c.fill(); +} + +// Markdown-текст: цвет берётся из c.fillStyle; свободный текст обводится для читаемости на любом фоне. +function drawText(c, s, x, y, align) { + const layout = layoutText(s); + const fill = c.fillStyle; + const outline = s.type === 'text' ? (s.color === '#111111' ? 'rgba(255,255,255,.7)' : 'rgba(0,0,0,.55)') : null; + c.textAlign = 'left'; + c.textBaseline = 'top'; + let ly = y; + for (const line of layout.lines) { + // По центру — весь текст как целое; строки внутри выровнены по левому краю (так ровнее списки). + let lx = align === 'center' ? x - layout.w / 2 : x; + for (const run of line.runs) { + const pad = run.code ? CODE_PAD : 0; + if (run.code) { + c.fillStyle = 'rgba(127,127,127,.3)'; + c.beginPath(); + c.roundRect(lx, ly - 1, run.w, line.size * 1.15, 4); + c.fill(); + c.fillStyle = fill; + } + c.font = run.font; + if (outline) { + c.lineWidth = 4; + c.strokeStyle = outline; + c.strokeText(run.text, lx + pad, ly); + } + c.fillText(run.text, lx + pad, ly); + if (run.strike) c.fillRect(lx + pad, ly + line.size * 0.55, run.w - pad * 2, Math.max(1, line.size / 14)); + lx += run.w; + } + ly += line.h; + } +} + +function drawPen(c, s) { + const p = s.points; + c.beginPath(); + c.moveTo(p[0].x, p[0].y); + if (p.length === 1) c.lineTo(p[0].x + 0.01, p[0].y); + for (let i = 1; i < p.length - 1; i++) { + c.quadraticCurveTo(p[i].x, p[i].y, (p[i].x + p[i + 1].x) / 2, (p[i].y + p[i + 1].y) / 2); + } + if (p.length > 1) c.lineTo(p[p.length - 1].x, p[p.length - 1].y); + c.stroke(); +} + +// Начало координат — в центре фигуры, оси повёрнуты вместе с ней. +export function enterShape(c, s) { + c.translate(s.x + s.w / 2, s.y + s.h / 2); + c.rotate(s.rotation || 0); +} + +function drawImageShape(c, s) { + const image = getImage(s.imageId); + if (!image) return; + enterShape(c, s); + c.shadowColor = 'rgba(0,0,0,.45)'; + c.shadowBlur = 14; + c.drawImage(image, -s.w / 2, -s.h / 2, s.w, s.h); +} + +// editing — фигура, чей текст сейчас в поле ввода: её текст не рисуем, чтобы не двоился. +export function drawShape(c, s, editing = null) { + c.save(); + c.strokeStyle = s.color; + c.fillStyle = s.color; + c.lineWidth = s.width; + c.lineCap = 'round'; + c.lineJoin = 'round'; + switch (s.type) { + case 'pen': + drawPen(c, s); + break; + case 'line': + case 'arrow': { + const l = resolveLine(s); + c.beginPath(); + c.moveTo(l.x1, l.y1); + c.lineTo(l.x2, l.y2); + c.stroke(); + if (s.type === 'arrow') drawArrowHead(c, l.x1, l.y1, l.x2, l.y2, s.width); + break; + } + case 'rect': + c.strokeRect(s.x, s.y, s.w, s.h); + break; + case 'ellipse': + c.beginPath(); + c.ellipse(s.x + s.w / 2, s.y + s.h / 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2); + c.stroke(); + break; + case 'node': + c.beginPath(); + c.roundRect(s.x, s.y, s.w, s.h, 12); + c.fillStyle = 'rgba(20,20,24,.9)'; + c.fill(); + c.stroke(); + if (s !== editing) { + c.fillStyle = '#fff'; + drawText(c, s, s.x + s.w / 2, s.y + (s.h - layoutText(s).h) / 2, 'center'); + } + break; + case 'text': + if (s !== editing) drawText(c, s, s.x, s.y, 'left'); + break; + case 'image': + drawImageShape(c, s); + break; + default: + } + c.restore(); +} From 7e113865ac0e643a1a3eb65b73ff03323c15e865 Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 11:06:09 +0300 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=87=D0=B8=D1=82=D1=8C=20=D0=B8=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=8E=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=B1=D1=8A=D1=91=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кроме 200 шагов — до ~50 млн символов JSON: длинные штрихи кистью — тысячи точек, и 200 полных копий холста заняли бы сотни мегабайт в программе, что живёт фоном. Последний шаг отмены остаётся всегда. Co-Authored-By: Claude Opus 5.5 --- src/core/history.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/history.js b/src/core/history.js index 254b567..eb79f37 100644 --- a/src/core/history.js +++ b/src/core/history.js @@ -1,6 +1,9 @@ import { state } from './state.js'; const LIMIT = 200; +// И по объёму: длинные штрихи кистью — тысячи точек, 200 полных копий холста заняли бы сотни МБ. +const MAX_CHARS = 50_000_000; +const stackChars = () => undoStack.reduce((sum, json) => sum + json.length, 0); let committed = '[]'; let revision = 0; // Растёт при каждом изменении истории — по нему видно, менял ли что-то конкретный клик. @@ -12,7 +15,7 @@ export function commit() { const now = JSON.stringify(state.shapes); if (now === committed) return; undoStack.push(committed); - if (undoStack.length > LIMIT) undoStack.shift(); + while (undoStack.length > LIMIT || (undoStack.length > 1 && stackChars() > MAX_CHARS)) undoStack.shift(); redoStack.length = 0; committed = now; revision++; From 786207c04ea08cf473017db12f69108e4cb602aa Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 11:06:09 +0300 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=BA=D1=80=D0=B8=D0=BD=D1=88=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хоткей или двойной тап Alt посреди выделения оставляли замороженный кадр поверх экрана. Co-Authored-By: Claude Opus 5.5 --- src/main.js | 7 +++++-- src/ui/screenshot.js | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main.js b/src/main.js index 0895c2a..6248dff 100644 --- a/src/main.js +++ b/src/main.js @@ -7,7 +7,7 @@ import { setPlatform, formatKeys } from './ui/keys.js'; import { initKeyboard } from './ui/keyboard.js'; import { initPointer } from './ui/pointer.js'; import { initSettings } from './ui/settings.js'; -import { initScreenshot } from './ui/screenshot.js'; +import { initScreenshot, cancelScreenshot } from './ui/screenshot.js'; import { selectTool } from './ui/toolbar.js'; import { state } from './core/state.js'; import { createTools } from './tools/index.js'; @@ -32,7 +32,10 @@ api.on('overlay:show', () => { // Глобальный хоткей занят другой программой — без подсказки не понять, почему он не работает. if (!info.hotkeyOk) showToast(`${formatKeys(info.hotkey)} — ${t('settings.hotkey.taken')}`); }); -api.on('overlay:toggle', toggleMode); +api.on('overlay:toggle', () => { + cancelScreenshot(); + toggleMode(); +}); // Зажат Alt: мышь уходит окнам под холстом, рисунок полупрозрачный. api.on('overlay:peek', (held) => document.body.classList.toggle('peek', held)); // Окно стартует спрятанным; режим рисования включится событием overlay:show при первом показе. diff --git a/src/ui/screenshot.js b/src/ui/screenshot.js index ff5f3c9..5388c52 100644 --- a/src/ui/screenshot.js +++ b/src/ui/screenshot.js @@ -14,12 +14,19 @@ const ctx = layer.getContext('2d'); const MIN_SIZE = 8; let busy = false; +let cancelSelection = null; // пока идёт выделение области let onPlaced = () => {}; export function initScreenshot({ placed }) { onPlaced = placed; } +// Режим переключили посреди выделения (хоткей, двойной тап Alt) — выделение отменяется, +// иначе замороженный кадр остался бы висеть поверх экрана. +export function cancelScreenshot() { + cancelSelection?.(); +} + export async function takeScreenshot() { if (busy) return; busy = true; @@ -92,6 +99,7 @@ function selectRegion(frame) { }; function finish(result) { + cancelSelection = null; layer.removeEventListener('pointerdown', onDown); layer.removeEventListener('pointermove', onMove); layer.removeEventListener('pointerup', onUp); @@ -106,6 +114,7 @@ function selectRegion(frame) { layer.addEventListener('pointermove', onMove); layer.addEventListener('pointerup', onUp); addEventListener('keydown', onKey, true); + cancelSelection = () => finish(null); document.body.classList.add('shooting'); layer.hidden = false; paint(); From 2492deffb7dfc10275817dbaef022d0e6e7d17b0 Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 11:06:09 +0300 Subject: [PATCH 08/12] =?UTF-8?q?ci:=20Rust=20=D0=BD=D0=B0=20Linux,=20Wind?= =?UTF-8?q?ows=20=D0=B8=20macOS,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У каждой системы свой платформенный код (опрос Alt, окно, диалоги) — он должен компилироваться до релиза, а не впервые на теге. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8583fc..62315fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,8 @@ concurrency: cancel-in-progress: true jobs: - check: - name: Checks + interface: + name: Interface runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -33,28 +33,51 @@ jobs: node-version: 22 cache: npm - - name: Interface + - name: Syntax, imports, dictionaries, core tests run: | npm ci --no-audit --no-fund npm run check npm test + # На всех трёх системах: у каждой свой платформенный код (опрос Alt, окно, диалоги), + # и он должен компилироваться до релиза, а не впервые на теге. + rust: + name: Rust (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: Linux + os: ubuntu-24.04 + - name: Windows + os: windows-latest + - name: macOS + os: macos-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - name: Linux system libraries + if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libwebkit2gtk-4.1-dev librsvg2-dev libayatana-appindicator3-dev patchelf \ - pkg-config libclang-dev libxcb1-dev libxrandr-dev libdbus-1-dev \ - libpipewire-0.3-dev libwayland-dev libegl-dev libgbm-dev + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev librsvg2-dev libayatana-appindicator3-dev patchelf pkg-config libclang-dev libxcb1-dev libxrandr-dev libdbus-1-dev libpipewire-0.3-dev libwayland-dev libegl-dev libgbm-dev - uses: dtolnay/rust-toolchain@stable with: - components: clippy + components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri + - name: Format + if: runner.os == 'Linux' + working-directory: src-tauri + run: cargo fmt --check + - name: Clippy working-directory: src-tauri run: cargo clippy --all-targets --locked -- -D warnings From 694b010dc0dae1f092faf462e788aba4a095a01b Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 11:06:09 +0300 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20=D0=BA=D0=B0=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=D0=BA=D0=B0=20=D0=B2=20=D0=B1=D1=83=D1=84=D0=B5=D1=80,?= =?UTF-8?q?=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=B8=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20?= =?UTF-8?q?PNG=20=D1=81=D0=BE=20=D0=B2=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=20=D1=85=D0=BE=D0=BB=D1=81=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ctrl+Shift+C — рисунок картинкой в буфер обмена, вставить в чат созвона (tauri-plugin-clipboard-manager) - Ctrl+S — PNG, который открывается любым просмотрщиком и содержит холст (чанк iTXt, как у Excalidraw); Ctrl+O — открыть для правки, прежний холст — через Ctrl+Z - системные диалоги (tauri-plugin-dialog) с окном холста как владельцем — не прячутся под ним; путь выбирает человек - открываемый файл проверяется: только известные фигуры с корректными полями, размер до 256 МБ - кнопки на панели, памятка, 11 языков, тесты формата PNG и сцены, документы Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 9 +- README.md | 6 + README.ru.md | 3 + docs/ROADMAP.md | 17 +- src-tauri/Cargo.lock | 355 ++++++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 4 + src-tauri/src/files.rs | 144 +++++++++++++++++ src-tauri/src/lib.rs | 8 +- src/core/png.js | 69 ++++++++ src/core/scene.js | 58 +++++++ src/core/state.js | 2 + src/i18n/ar.js | 12 ++ src/i18n/de.js | 12 ++ src/i18n/en.js | 12 ++ src/i18n/es.js | 12 ++ src/i18n/fr.js | 12 ++ src/i18n/hi.js | 12 ++ src/i18n/ja.js | 12 ++ src/i18n/ko.js | 12 ++ src/i18n/pt.js | 12 ++ src/i18n/ru.js | 12 ++ src/i18n/zh.js | 12 ++ src/index.html | 3 + src/ui/api.js | 4 + src/ui/export.js | 158 ++++++++++++++++++ src/ui/help.js | 2 + src/ui/keyboard.js | 4 + src/ui/toolbar.js | 8 + tests/core.test.js | 46 ++++++ 29 files changed, 1019 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/files.rs create mode 100644 src/core/png.js create mode 100644 src/core/scene.js create mode 100644 src/ui/export.js diff --git a/CLAUDE.md b/CLAUDE.md index 37cac01..6efcf42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,10 +23,10 @@ ### Зависимости между слоями (строго вниз, без циклов) ``` -src-tauri/ (Rust: окно, сквозные клики, трей, хоткей, настройки, снимок экрана, обновления) ←команды/события→ src/ui/api.js +src-tauri/ (Rust: окно, сквозные клики, трей, хоткей, настройки, снимок экрана, файлы и буфер, обновления) ←команды/события→ src/ui/api.js src/ui/ холст, панель, настройки, ввод, режимы (верхний слой) src/tools/ инструменты с единым интерфейсом down/move/up -src/core/ состояние, фигуры, геометрия, история, Markdown, картинки (чистая логика) +src/core/ состояние, фигуры, геометрия, история, Markdown, картинки, формат файла (чистая логика) src/i18n/ словари; используется только из ui ``` - Инструмент получает от UI только `host = { render, edit }` — так `tools` не импортирует `ui`. @@ -34,7 +34,7 @@ src/i18n/ словари; используется только из ui - `core` не импортирует `tools`/`ui`; `tools` не импортирует `ui`. Циклических импортов нет. - Общее состояние — в одном месте (`core/state.js`), мутации через явные функции. - Rust-сторона ничего не знает о рисовании; интерфейс ничего не знает об окнах ОС. -- В Rust: один модуль — одна ответственность (`overlay`, `keys`, `hotkey`, `tray`, `settings`, `capture`, `updates`), команды — тонкие обёртки в `lib.rs`. +- В Rust: один модуль — одна ответственность (`overlay`, `keys`, `hotkey`, `tray`, `settings`, `capture`, `files`, `updates`), команды — тонкие обёртки в `lib.rs`. - Окно трогаем только из главного потока: фоновые потоки лишь ставят задачи (`run_on_main_thread`) и не ждут их, замки не держат через вызовы окна — иначе взаимная блокировка. ## Безопасность (Tauri) @@ -43,7 +43,8 @@ src/i18n/ словари; используется только из ui - Строгий CSP в `tauri.conf.json` (`default-src 'self'`, без inline-скриптов и стилей). Стили из JS — только через CSSOM (`el.style.x = …`), не атрибутом. - Не использовать `eval`, `new Function`, `innerHTML` с данными — только `textContent`/`createElement`. - Сеть — только обновления через хаб (и запасной GitHub). Подпись каждого обновления проверяется ключом из конфига; приватный ключ — только в CI, никогда в репозитории. -- На диск пишем только настройки в каталоге конфигурации приложения (и запись автозапуска средствами системы); битые настройки → значения по умолчанию, не падение. +- На диск пишем только настройки в каталоге конфигурации приложения, запись автозапуска средствами системы и файлы, которые человек сам выбрал в системном диалоге (путь интерфейс не задаёт). Битые настройки → значения по умолчанию, не падение. +- Открываемые файлы — недоверенные: сцену проверяет `core/scene.js` (известные фигуры, корректные поля), размер файла ограничен. - Ничего личного наружу: в хаб уходит только случайный `install_id`. - Перед релизом: `cargo clippy -- -D warnings`, `npm audit`, актуальные Tauri и плагины. diff --git a/README.md b/README.md index 6c76d0a..7a01f4a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Sketchover puts a transparent canvas over every window. Press one hotkey on a Zo - **Text blocks with Markdown**: headings, bullet and numbered lists (they continue on `Enter`), **bold**, *italic*, `code`, ~~strikethrough~~. - **Arrows that stick**: arrows attach to text blocks and screenshots and follow them when you move them. - **Region screenshots**: press `S`, select part of the screen, and it lands on the canvas — move it, resize it, rotate it. +- **Paste into the chat**: `Ctrl+Shift+C` copies your drawing as an image — paste it into Zoom, Teams, Slack or Telegram. +- **Save and keep editing**: `Ctrl+S` saves a PNG that opens anywhere and still contains the editable canvas — open it later with `Ctrl+O`. - **Keyboard first, layout independent**: `1`…`0` pick tools, `Alt+1…8` colors, `Shift+1…3` stroke width, `F1` shows a cheat sheet. - **Always ready, never in the way**: starts with your system and waits in the tray; one global hotkey (`` Alt+` ``, configurable) opens it on the monitor under your cursor. - **Small and private**: a ~3 MB installer, no account, nothing leaves your computer except an anonymous update check. @@ -61,6 +63,8 @@ Sketchover opens the canvas right after install, then lives in the tray (the men | Region screenshot | `S` | | Color / stroke width | `Alt+1…8` / `Shift+1…3` | | Undo / redo | `Ctrl+Z` / `Ctrl+Y` | +| Copy the drawing as an image | `Ctrl+Shift+C` | +| Save / open (PNG with the editable canvas inside) | `Ctrl+S` / `Ctrl+O` | | Delete selected / clear all | `Delete` / `Shift+Delete` | | Edit a text block | double-click it with any tool | | Cheat sheet | `F1` | @@ -79,6 +83,8 @@ If you are looking for an **Epic Pen, ZoomIt, gInk, ppInk or Presentify alternat **Does it collect data?** No account, no analytics in the canvas. The only network request is the update check, sent with a random install ID. +**How do I keep a drawing?** `Ctrl+S` saves a regular PNG — it opens in any image viewer — with the canvas embedded, so `Ctrl+O` brings it back fully editable. Or `Ctrl+Shift+C` and paste it straight into the chat. + **Is everything the same on Linux?** Almost: holding or double-tapping `Alt` is Windows and macOS only for now. On Linux, switch click-through with `Esc`, the mode button or the global hotkey. ## Build from source diff --git a/README.ru.md b/README.ru.md index f231c23..8a3c361 100644 --- a/README.ru.md +++ b/README.ru.md @@ -55,6 +55,8 @@ npm start | Скриншот области | `S` или 📷 на панели → выделить область мышью (`Esc` — отмена) | | Цвет / толщина | `Alt+1…8` / `Shift+1…3` или кнопки на панели | | Отмена / повтор | `Ctrl+Z` / `Ctrl+Y` | +| Картинку в буфер (вставить в чат) | `Ctrl+Shift+C` или кнопка на панели | +| Сохранить / открыть (PNG с холстом внутри) | `Ctrl+S` / `Ctrl+O` или кнопки на панели | | Удалить выделенное / очистить всё | `Delete` / `Shift+Delete` | | Памятка по клавишам | `F1` или `?` на панели (по умолчанию открыта справа) | | Настройки, обновления, о программе | ⚙ на панели | @@ -63,6 +65,7 @@ npm start - **Markdown** в блоках и тексте: `#`/`##`/`###` заголовки, `-` и `1.` списки, `**жирный**`, `*курсив*`, `` `код` ``, `~~зачёркнутый~~`. Блок растёт под текст. - **Скриншоты** появляются на месте выделенной области и сразу выделены: тащи, тяни за угол (пропорции сохраняются), вращай за круглую ручку сверху (прилипает к шагу 15°). - **Стрелки** начинаются и заканчиваются на блоках и скриншотах и тянутся за ними. +- **Сохранение**: `Ctrl+S` сохраняет обычный PNG (открывается любым просмотрщиком) со встроенным холстом — `Ctrl+O` открывает его снова для правки; прежний холст возвращается через `Ctrl+Z`. `Ctrl+Shift+C` — картинка в буфер, сразу вставить в чат созвона. - Клавиши — по физическим цифрам, работают на любой раскладке. - В режиме «сквозные клики» и при зажатом `Alt` рисунок полупрозрачный, клики и прокрутка идут в окна под ним, панель остаётся кликабельной. - Холст открывается на мониторе, где курсор. Чтобы зрители видели рисунки, делись всем экраном, а не окном. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c219777..195207a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,7 +8,7 @@ 2. Рисуешь сразу, без настроек: кисть, стрелки, блоки с текстом, скриншоты. ✅ 3. Зажал `Alt` — рисунок полупрозрачный, мышь работает с окнами под ним; отпустил — снова рисуешь. ✅ (Windows, macOS) 4. Зажатое колесо — двигаешь холст, `Ctrl+колесо` — масштаб. — впереди -5. Сохранил одной кнопкой — файл открывается где угодно и при этом редактируется дальше. — впереди +5. Сохранил одной кнопкой — файл открывается где угодно и при этом редактируется дальше. ✅ 6. Обновления приходят сами, через ProAnima Hub, как у остальных приложений студии. ◐ (в приложении готово, нужен первый релиз) ## Сделано и как устроено @@ -43,7 +43,14 @@ - **Скриншот области** (`S` или 📷): холст прячется → `xcap` снимает монитор → выделение мышью на замороженном кадре → кусок становится фигурой `image` на том же месте, сразу выделен: перемещение, масштаб за угол с сохранением пропорций, поворот за ручку (прилипание к 15°). - Картинки хранятся отдельно от фигур (`core/images.js`), фигура ссылается по id — история отмены не копирует мегабайты. - Оверлей нельзя скрывать от захвата экрана (content protection) — его перестанут видеть зрители демонстрации. -- Перерисовка склеивается в один кадр (`requestAnimationFrame`); в простое кадры не рисуются. +- Перерисовка склеивается в один кадр (`requestAnimationFrame`); в простое кадры не рисуются. Фигуры рисует `ui/shapes-draw.js` в любой контекст — на экран и в картинку для экспорта. +- История отмены — до 200 шагов и до ~50 млн символов JSON (длинные штрихи кистью); картинки, на которые не ссылаются ни холст, ни история, освобождаются перед новым скриншотом. + +### Сохранение и буфер обмена +- **`Ctrl+Shift+C`** — рисунок картинкой в буфер (прозрачный фон, по границам рисунка, в разрешении экрана) — вставить в чат созвона. Через `tauri-plugin-clipboard-manager` в Rust: Web Clipboard API зависит от фокуса и «жеста пользователя» и по-разному работает в WebView разных систем. +- **`Ctrl+S`** — PNG со встроенной сценой: чанк `iTXt` (UTF-8; `tEXt` — только Latin-1), как у Excalidraw. Файл открывается любым просмотрщиком, а **`Ctrl+O`** открывает его в Sketchover для правки (прежний холст — через `Ctrl+Z`). Картинки скриншотов — внутри сцены в base64. +- Диалоги — `tauri-plugin-dialog` в Rust, с окном холста как владельцем (иначе диалог открылся бы под полноэкранным окном поверх всех). Путь выбирает человек; интерфейс передаёт только байты и подсказку имени. +- Открываемый файл недоверенный: `core/scene.js` оставляет только известные фигуры с корректными полями, ссылки стрелок — только на оставшиеся фигуры; размер файла — до 256 МБ. ### Настройки и «О программе» — раскрываемая панель - ⚙ на панели раскрывает компактную панель под ней; повторный клик или `Esc` — свернуть. @@ -62,7 +69,7 @@ - Без платной подписи Windows SmartScreen и macOS Gatekeeper предупреждают при первом запуске; ad-hoc подпись на macOS убирает «приложение повреждено». Сертификат подписи для Windows и Apple Developer ID с нотаризацией подключаются секретами в `release.yml` без изменения кода. ### Сборка и релизы -- `ci.yml` — на каждый пуш и PR: `npm run check` (синтаксис модулей, импорты, словари), `cargo clippy -D warnings`, `cargo test`. +- `ci.yml` — на каждый пуш и PR: `npm run check` (синтаксис модулей, импорты, словари) и `npm test` (тесты ядра); `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` — на Linux, Windows и macOS. - `release.yml` — тег `vX.Y.Z` (`npm version patch|minor|major` + `git push --follow-tags`): проверки → черновик релиза → установщики трёх систем, подписанные для автообновления, + `latest.json`. Публикация черновика — вручную. ### Обновления через ProAnima Hub @@ -83,13 +90,13 @@ | 6 | Блоки с Markdown, правка двойным кликом | ✅ | | 7 | Стрелки: привязка к блокам и скриншотам ✅; подсветка цели при наведении, перетаскивание концов, привязка к прямоугольникам и эллипсам | ◐ | | 8 | Камера: мировые координаты, панорама зажатым колесом, `Ctrl+колесо` — масштаб | впереди | -| 9 | Сохранение: PNG со встроенной сценой (JSON в чанке `tEXt`, как у Excalidraw — открывается везде и редактируется дальше), `Ctrl+S`, `Ctrl+Shift+C` — картинка в буфер | впереди | +| 9 | Сохранение: PNG со встроенной сценой, открытие, картинка в буфер | ✅ | | 10 | Блоки: цвет заливки и текста, мини-панель у выделенной фигуры | впереди | | 11 | Отзывчивость: `getCoalescedEvents()` для кисти, `desynchronized`-контекст, статика и динамика на разных canvas | впереди, по мере роста нагрузки | ## Открытые вопросы - Один холст или несколько досок? Пока — один. -- Автосейв последнего холста при закрытии — склоняюсь к «да» (после этапа 9). +- Автосейв последнего холста при выходе и обновлении — склоняюсь к «да»: формат файла уже есть. - Отзывы через хаб (форма + скриншот холста) — хаб умеет; нужно ли в Sketchover? - Статистика использования через хаб: сейчас не отправляется; включать ли и по умолчанию ли. - Удержание и двойной тап `Alt` на Linux X11 (на Wayland невозможно). diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0293a3a..d5e5bd6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -72,6 +72,27 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -597,6 +618,15 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "combine" version = "4.6.8" @@ -724,6 +754,12 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -949,7 +985,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -983,7 +1019,7 @@ checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", @@ -1167,6 +1203,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "event-listener" version = "5.4.2" @@ -1193,6 +1235,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1228,6 +1276,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1245,6 +1299,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1735,12 +1795,32 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -2035,6 +2115,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "tiff", ] [[package]] @@ -3093,6 +3174,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "osakit" version = "0.3.1" @@ -3167,6 +3258,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", +] + [[package]] name = "phf" version = "0.13.1" @@ -3425,6 +3527,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.41.0" @@ -3594,6 +3702,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4090,6 +4222,8 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-single-instance", "tauri-plugin-updater", @@ -4549,6 +4683,63 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333d3b6beeb1c2c7fc040233d4ea7b6c03a9f8f039592b7400833c43fb5b568a" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.7+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-global-shortcut" version = "2.4.0" @@ -4776,6 +4967,20 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.55" @@ -5053,6 +5258,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5491,6 +5707,12 @@ dependencies = [ "windows-core", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "widestring" version = "1.2.1" @@ -5683,6 +5905,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -5716,13 +5947,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -5753,6 +6001,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5765,6 +6019,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5777,12 +6037,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5795,6 +6067,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5807,6 +6085,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5819,6 +6103,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5831,6 +6121,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -5865,6 +6161,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" @@ -6110,6 +6424,26 @@ dependencies = [ "serde", ] +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -6194,6 +6528,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ff7af24..6429908 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,10 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-single-instance = "2" tauri-plugin-autostart = "2" tauri-plugin-updater = "2" +# Системные диалоги «Сохранить» / «Открыть» для файлов холста. +tauri-plugin-dialog = "2" +# Картинка холста в буфер обмена — надёжнее Web Clipboard API, который зависит от фокуса и «жеста». +tauri-plugin-clipboard-manager = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs new file mode 100644 index 0000000..1c2ac21 --- /dev/null +++ b/src-tauri/src/files.rs @@ -0,0 +1,144 @@ +//! Холст наружу: картинка в буфер обмена, сохранение и открытие (PNG со сценой внутри) +//! через системные диалоги. +//! Путь выбирает человек в диалоге — интерфейс путей не задаёт и записать куда-то ещё не может. + +use std::fs; +use std::path::Path; + +use tauri::ipc::{InvokeBody, Request, Response}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_plugin_dialog::{DialogExt, FileDialogBuilder}; + +use crate::overlay; + +/// Больше холст не бывает даже со множеством скриншотов 4K; защищает от чтения чужих огромных файлов. +const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; +const DEFAULT_NAME: &str = "Sketchover.png"; + +/// Имя файла из заголовка интерфейса — только как подсказка в диалоге: без каталогов, с .png. +fn suggested_name(request: &Request<'_>) -> String { + let name = request + .headers() + .get("x-file-name") + .and_then(|v| v.to_str().ok()) + .and_then(percent_decode) + .and_then(|v| { + Path::new(&v) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| DEFAULT_NAME.into()); + if name.to_lowercase().ends_with(".png") { + name + } else { + format!("{name}.png") + } +} + +/// Заголовки HTTP — только ASCII, поэтому интерфейс кодирует имя через encodeURIComponent. +fn percent_decode(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +/// Диалог, привязанный к окну холста: холст — полноэкранное окно поверх всех, и диалог без +/// владельца открылся бы под ним — невидимым и недоступным для клика. +fn file_dialog(app: &AppHandle) -> FileDialogBuilder { + let mut dialog = app.dialog().file().add_filter("PNG", &["png"]); + if let Some(win) = app.get_webview_window(overlay::LABEL) { + dialog = dialog.set_parent(&win); + } + if let Ok(dir) = app.path().picture_dir() { + dialog = dialog.set_directory(dir); + } + dialog +} + +/// Тело запроса — байты PNG. Возвращает имя сохранённого файла или `None`, если диалог закрыли. +#[tauri::command] +pub async fn save_png(app: AppHandle, request: Request<'_>) -> Result, String> { + let InvokeBody::Raw(bytes) = request.body() else { + return Err("invalid".into()); + }; + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err("invalid".into()); + } + let dialog = file_dialog(&app).set_file_name(suggested_name(&request)); + // Блокирующий диалог — в асинхронной команде, то есть не в главном потоке. + let Some(path) = dialog.blocking_save_file() else { + return Ok(None); + }; + let path = path.into_path().map_err(|e| e.to_string())?; + fs::write(&path, bytes).map_err(|e| e.to_string())?; + Ok(path.file_name().map(|n| n.to_string_lossy().into_owned())) +} + +/// Тело запроса — пиксели RGBA, размер — в заголовках `x-width` / `x-height`. +#[tauri::command] +pub fn copy_image(app: AppHandle, request: Request<'_>) -> Result<(), String> { + let InvokeBody::Raw(rgba) = request.body() else { + return Err("invalid".into()); + }; + let dim = |name: &str| { + request + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0) + }; + let (Some(width), Some(height)) = (dim("x-width"), dim("x-height")) else { + return Err("invalid".into()); + }; + if rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 { + return Err("invalid".into()); + } + app.clipboard() + .write_image(&tauri::image::Image::new(rgba, width, height)) + .map_err(|e| e.to_string()) +} + +/// Байты выбранного PNG; пустой ответ — диалог закрыли. +#[tauri::command] +pub async fn open_png(app: AppHandle) -> Result { + let Some(path) = file_dialog(&app).blocking_pick_file() else { + return Ok(Response::new(Vec::new())); + }; + let path = path.into_path().map_err(|e| e.to_string())?; + let size = fs::metadata(&path).map_err(|e| e.to_string())?.len(); + if size > MAX_FILE_BYTES { + return Err("too large".into()); + } + fs::read(&path) + .map(Response::new) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::percent_decode; + + #[test] + fn decodes_names_from_header() { + assert_eq!( + percent_decode("%D0%A1%D1%85%D0%B5%D0%BC%D0%B0.png").as_deref(), + Some("Схема.png") + ); + assert_eq!(percent_decode("plain.png").as_deref(), Some("plain.png")); + assert_eq!(percent_decode("bad%G1"), None); + assert_eq!(percent_decode("cut%D"), None); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c54a67..120cb54 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,8 @@ -//! Процесс ОС: окно-оверлей, трей, глобальный хоткей, настройки, обновления. +//! Процесс ОС: окно-оверлей, трей, глобальный хоткей, настройки, файлы холста, обновления. //! О рисовании здесь ничего не знают — это целиком интерфейс в `src/`. mod capture; +mod files; mod hotkey; mod keys; mod overlay; @@ -148,6 +149,8 @@ pub fn run() { Some(vec![HIDDEN_ARG]), )) .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) .manage(overlay::ClickThrough::default()) .manage(hotkey::Status::default()) .manage(updates::Pending::default()) @@ -184,6 +187,9 @@ pub fn run() { updates::check_update, updates::install_update, capture::capture_screen, + files::copy_image, + files::save_png, + files::open_png, ]) .run(tauri::generate_context!()) .expect("не удалось запустить Sketchover"); diff --git a/src/core/png.js b/src/core/png.js new file mode 100644 index 0000000..e005ca8 --- /dev/null +++ b/src/core/png.js @@ -0,0 +1,69 @@ +// Текстовый чанк внутри PNG: картинка открывается любым просмотрщиком, а в чанке лежит сцена +// для повторной правки (так делает Excalidraw). Чанк iTXt, а не tEXt: tEXt — только Latin-1, +// а в сцене кириллица и другие языки. + +const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(bytes) { + let c = 0xffffffff; + for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +const isPng = (bytes) => bytes.length > 8 && SIGNATURE.every((b, i) => bytes[i] === b); +const ascii = (s) => Uint8Array.from(s, (ch) => ch.charCodeAt(0)); + +// Чанки PNG: { type, data, start, end } по порядку. +function* chunks(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let pos = 8; + while (pos + 12 <= bytes.length) { + const length = view.getUint32(pos); + const end = pos + 12 + length; + if (end > bytes.length) return; + const type = String.fromCharCode(...bytes.subarray(pos + 4, pos + 8)); + yield { type, data: bytes.subarray(pos + 8, pos + 8 + length), start: pos, end }; + pos = end; + } +} + +// Новый PNG с чанком iTXt `keyword` → `text` (UTF-8) перед IEND. +export function withTextChunk(png, keyword, text) { + if (!isPng(png)) throw new Error('not a PNG'); + const iend = [...chunks(png)].find((c) => c.type === 'IEND'); + if (!iend) throw new Error('PNG without IEND'); + // keyword \0 | сжатие 0 | метод 0 | язык \0 | перевод ключа \0 | текст + const data = new Uint8Array([...ascii(keyword), 0, 0, 0, 0, 0, ...new TextEncoder().encode(text)]); + const chunk = new Uint8Array(12 + data.length); + const view = new DataView(chunk.buffer); + view.setUint32(0, data.length); + chunk.set(ascii('iTXt'), 4); + chunk.set(data, 8); + view.setUint32(8 + data.length, crc32(chunk.subarray(4, 8 + data.length))); + const out = new Uint8Array(png.length + chunk.length); + out.set(png.subarray(0, iend.start)); + out.set(chunk, iend.start); + out.set(png.subarray(iend.start), iend.start + chunk.length); + return out; +} + +// Текст чанка iTXt с ключом `keyword` (без сжатия) или null. +export function readTextChunk(png, keyword) { + if (!isPng(png)) return null; + const key = ascii(`${keyword}\0`); + for (const c of chunks(png)) { + if (c.type !== 'iTXt' || !key.every((b, i) => c.data[i] === b)) continue; + const flags = key.length; // флаг сжатия, затем метод + if (c.data[flags] !== 0) return null; + let pos = flags + 2; + for (let zeros = 0; zeros < 2 && pos < c.data.length; pos++) if (c.data[pos] === 0) zeros++; + return new TextDecoder().decode(c.data.subarray(pos)); + } + return null; +} diff --git a/src/core/scene.js b/src/core/scene.js new file mode 100644 index 0000000..43d9c13 --- /dev/null +++ b/src/core/scene.js @@ -0,0 +1,58 @@ +// Сцена для файла: фигуры + картинки (PNG в base64). Файл приходит извне — не доверяем ему: +// оставляем только известные фигуры с корректными полями, остальное отбрасываем. + +export const SCENE_KEY = 'sketchover'; +const VERSION = 1; + +const TYPES = new Set(['pen', 'line', 'arrow', 'rect', 'ellipse', 'node', 'text', 'image']); +const BOX = ['x', 'y', 'w', 'h']; +const FIELDS = { + pen: [], + line: ['x1', 'y1', 'x2', 'y2'], + arrow: ['x1', 'y1', 'x2', 'y2'], + rect: BOX, + ellipse: BOX, + node: [...BOX, 'size'], + text: ['x', 'y', 'size'], + image: [...BOX], +}; + +const isNum = (v) => typeof v === 'number' && Number.isFinite(v); +const isColor = (v) => typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v); + +export function packScene(shapes, images) { + return JSON.stringify({ app: SCENE_KEY, version: VERSION, shapes, images }); +} + +function validShape(s) { + if (!s || typeof s !== 'object' || !TYPES.has(s.type)) return false; + if (!Number.isSafeInteger(s.id) || s.id <= 0 || !isColor(s.color) || !isNum(s.width)) return false; + if (!FIELDS[s.type].every((f) => isNum(s[f]))) return false; + if (s.type === 'pen') return Array.isArray(s.points) && s.points.length > 0 && s.points.every((p) => isNum(p?.x) && isNum(p?.y)); + if (s.type === 'node' || s.type === 'text') return typeof s.text === 'string'; + if (s.type === 'image') return typeof s.imageId === 'string' && (s.rotation === undefined || isNum(s.rotation)); + if (s.type === 'line' || s.type === 'arrow') return [s.from, s.to].every((v) => v === null || v === undefined || Number.isSafeInteger(v)); + return true; +} + +// JSON сцены → { shapes, images } или null, если это не сцена Sketchover. +export function unpackScene(json) { + let data; + try { + data = JSON.parse(json); + } catch { + return null; + } + if (data?.app !== SCENE_KEY || !Array.isArray(data.shapes)) return null; + const images = data.images && typeof data.images === 'object' ? data.images : {}; + const shapes = data.shapes.filter(validShape) + .filter((s) => s.type !== 'image' || typeof images[s.imageId] === 'string'); + // Ссылки стрелок — только на фигуры, которые остались. + const ids = new Set(shapes.map((s) => s.id)); + for (const s of shapes) { + if (s.type !== 'line' && s.type !== 'arrow') continue; + if (!ids.has(s.from)) s.from = null; + if (!ids.has(s.to)) s.to = null; + } + return { shapes, images }; +} diff --git a/src/core/state.js b/src/core/state.js index 7fc7b34..c01bbfd 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -16,3 +16,5 @@ export const state = { let lastId = 0; export const newId = () => ++lastId; +// После открытия файла новые фигуры не должны получить id уже существующих. +export const reserveIds = (maxId) => { lastId = Math.max(lastId, maxId); }; diff --git a/src/i18n/ar.js b/src/i18n/ar.js index 3568a51..145e158 100644 --- a/src/i18n/ar.js +++ b/src/i18n/ar.js @@ -33,6 +33,18 @@ export default { 'shot.hint': 'اسحب لتحديد المنطقة · Esc للإلغاء', 'shot.failed': 'تعذّر التقاط الشاشة', 'help.shot': 'لقطة شاشة لمنطقة', + 'toolbar.copy': 'نسخ الصورة (Ctrl+Shift+C)', + 'toolbar.save': 'حفظ PNG (Ctrl+S)', + 'toolbar.open': 'فتح PNG (Ctrl+O)', + 'help.copy': 'نسخ الصورة', + 'help.save': 'حفظ / فتح', + 'file.copied': 'تم نسخ الصورة — الصقها في الدردشة', + 'file.copyFailed': 'تعذّر النسخ إلى الحافظة', + 'file.saved': 'تم الحفظ: {name}', + 'file.opened': 'تم فتح اللوحة', + 'file.empty': 'اللوحة فارغة', + 'file.notScene': 'لا يحتوي ملف PNG هذا على لوحة Sketchover', + 'file.failed': 'تعذّر التعامل مع الملف', 'mode.draw': '✏ وضع الرسم', 'mode.pass': '👆 تمرير النقرات', 'settings.language': 'اللغة', diff --git a/src/i18n/de.js b/src/i18n/de.js index 2f017a1..ec8747a 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -33,6 +33,18 @@ export default { 'shot.hint': 'Bereich mit der Maus aufziehen · Esc bricht ab', 'shot.failed': 'Bildschirm konnte nicht aufgenommen werden', 'help.shot': 'Bildschirmausschnitt', + 'toolbar.copy': 'Bild kopieren (Strg+Umschalt+C)', + 'toolbar.save': 'PNG speichern (Strg+S)', + 'toolbar.open': 'PNG öffnen (Strg+O)', + 'help.copy': 'Bild kopieren', + 'help.save': 'Speichern / öffnen', + 'file.copied': 'Bild kopiert — im Chat einfügen', + 'file.copyFailed': 'Kopieren in die Zwischenablage fehlgeschlagen', + 'file.saved': 'Gespeichert: {name}', + 'file.opened': 'Zeichenfläche geöffnet', + 'file.empty': 'Die Zeichenfläche ist leer', + 'file.notScene': 'Diese PNG enthält keine Sketchover-Zeichenfläche', + 'file.failed': 'Datei konnte nicht verarbeitet werden', 'mode.draw': '✏ Zeichnen', 'mode.pass': '👆 Durchklicken', 'settings.language': 'Sprache', diff --git a/src/i18n/en.js b/src/i18n/en.js index 718fe41..db1324e 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -33,6 +33,18 @@ export default { 'shot.hint': 'Drag to select a region · Esc to cancel', 'shot.failed': 'Could not capture the screen', 'help.shot': 'Screenshot a region', + 'toolbar.copy': 'Copy image (Ctrl+Shift+C)', + 'toolbar.save': 'Save PNG (Ctrl+S)', + 'toolbar.open': 'Open PNG (Ctrl+O)', + 'help.copy': 'Copy image', + 'help.save': 'Save / open', + 'file.copied': 'Image copied — paste it into the chat', + 'file.copyFailed': 'Could not copy to the clipboard', + 'file.saved': 'Saved: {name}', + 'file.opened': 'Canvas opened', + 'file.empty': 'The canvas is empty', + 'file.notScene': 'This PNG has no Sketchover canvas', + 'file.failed': 'Could not work with the file', 'mode.draw': '✏ Drawing', 'mode.pass': '👆 Click-through', 'settings.language': 'Language', diff --git a/src/i18n/es.js b/src/i18n/es.js index fe516a5..7c6508e 100644 --- a/src/i18n/es.js +++ b/src/i18n/es.js @@ -33,6 +33,18 @@ export default { 'shot.hint': 'Arrastra para elegir la zona · Esc para cancelar', 'shot.failed': 'No se pudo capturar la pantalla', 'help.shot': 'Captura de una zona', + 'toolbar.copy': 'Copiar imagen (Ctrl+Mayús+C)', + 'toolbar.save': 'Guardar PNG (Ctrl+S)', + 'toolbar.open': 'Abrir PNG (Ctrl+O)', + 'help.copy': 'Copiar imagen', + 'help.save': 'Guardar / abrir', + 'file.copied': 'Imagen copiada: pégala en el chat', + 'file.copyFailed': 'No se pudo copiar al portapapeles', + 'file.saved': 'Guardado: {name}', + 'file.opened': 'Lienzo abierto', + 'file.empty': 'El lienzo está vacío', + 'file.notScene': 'Este PNG no contiene un lienzo de Sketchover', + 'file.failed': 'No se pudo trabajar con el archivo', 'mode.draw': '✏ Dibujando', 'mode.pass': '👆 Clics a través', 'settings.language': 'Idioma', diff --git a/src/i18n/fr.js b/src/i18n/fr.js index b1aea7b..ae6f6f9 100644 --- a/src/i18n/fr.js +++ b/src/i18n/fr.js @@ -33,6 +33,18 @@ export default { 'shot.hint': 'Faites glisser pour choisir la zone · Échap pour annuler', 'shot.failed': 'Impossible de capturer l’écran', 'help.shot': 'Capture d’une zone', + 'toolbar.copy': 'Copier l’image (Ctrl+Maj+C)', + 'toolbar.save': 'Enregistrer en PNG (Ctrl+S)', + 'toolbar.open': 'Ouvrir un PNG (Ctrl+O)', + 'help.copy': 'Copier l’image', + 'help.save': 'Enregistrer / ouvrir', + 'file.copied': 'Image copiée — collez-la dans le chat', + 'file.copyFailed': 'Impossible de copier dans le presse-papiers', + 'file.saved': 'Enregistré : {name}', + 'file.opened': 'Canevas ouvert', + 'file.empty': 'Le canevas est vide', + 'file.notScene': 'Ce PNG ne contient pas de canevas Sketchover', + 'file.failed': 'Impossible de traiter le fichier', 'mode.draw': '✏ Dessin', 'mode.pass': '👆 Clics traversants', 'settings.language': 'Langue', diff --git a/src/i18n/hi.js b/src/i18n/hi.js index c831320..9853cb5 100644 --- a/src/i18n/hi.js +++ b/src/i18n/hi.js @@ -33,6 +33,18 @@ export default { 'shot.hint': 'क्षेत्र चुनने के लिए खींचें · रद्द करने के लिए Esc', 'shot.failed': 'स्क्रीन कैप्चर नहीं हो सकी', 'help.shot': 'क्षेत्र का स्क्रीनशॉट', + 'toolbar.copy': 'छवि कॉपी करें (Ctrl+Shift+C)', + 'toolbar.save': 'PNG सहेजें (Ctrl+S)', + 'toolbar.open': 'PNG खोलें (Ctrl+O)', + 'help.copy': 'छवि कॉपी करें', + 'help.save': 'सहेजें / खोलें', + 'file.copied': 'छवि कॉपी हो गई — चैट में पेस्ट करें', + 'file.copyFailed': 'क्लिपबोर्ड पर कॉपी नहीं हो सका', + 'file.saved': 'सहेजा गया: {name}', + 'file.opened': 'कैनवास खुल गया', + 'file.empty': 'कैनवास खाली है', + 'file.notScene': 'इस PNG में Sketchover कैनवास नहीं है', + 'file.failed': 'फ़ाइल के साथ काम नहीं हो सका', 'mode.draw': '✏ ड्रॉइंग', 'mode.pass': '👆 क्लिक-थ्रू', 'settings.language': 'भाषा', diff --git a/src/i18n/ja.js b/src/i18n/ja.js index aef1c47..36fbe25 100644 --- a/src/i18n/ja.js +++ b/src/i18n/ja.js @@ -33,6 +33,18 @@ export default { 'shot.hint': 'ドラッグで範囲を選択 · Esc でキャンセル', 'shot.failed': '画面をキャプチャできませんでした', 'help.shot': '範囲のスクリーンショット', + 'toolbar.copy': '画像をコピー (Ctrl+Shift+C)', + 'toolbar.save': 'PNG で保存 (Ctrl+S)', + 'toolbar.open': 'PNG を開く (Ctrl+O)', + 'help.copy': '画像をコピー', + 'help.save': '保存 / 開く', + 'file.copied': '画像をコピーしました。チャットに貼り付けてください', + 'file.copyFailed': 'クリップボードにコピーできませんでした', + 'file.saved': '保存しました: {name}', + 'file.opened': 'キャンバスを開きました', + 'file.empty': 'キャンバスは空です', + 'file.notScene': 'この PNG には Sketchover のキャンバスがありません', + 'file.failed': 'ファイルを処理できませんでした', 'mode.draw': '✏ 描画中', 'mode.pass': '👆 クリック透過', 'settings.language': '言語', diff --git a/src/i18n/ko.js b/src/i18n/ko.js index 611a222..f34a847 100644 --- a/src/i18n/ko.js +++ b/src/i18n/ko.js @@ -33,6 +33,18 @@ export default { 'shot.hint': '드래그하여 영역 선택 · Esc로 취소', 'shot.failed': '화면을 캡처할 수 없습니다', 'help.shot': '영역 스크린샷', + 'toolbar.copy': '이미지 복사 (Ctrl+Shift+C)', + 'toolbar.save': 'PNG 저장 (Ctrl+S)', + 'toolbar.open': 'PNG 열기 (Ctrl+O)', + 'help.copy': '이미지 복사', + 'help.save': '저장 / 열기', + 'file.copied': '이미지를 복사했습니다. 채팅에 붙여넣으세요', + 'file.copyFailed': '클립보드에 복사할 수 없습니다', + 'file.saved': '저장됨: {name}', + 'file.opened': '캔버스를 열었습니다', + 'file.empty': '캔버스가 비어 있습니다', + 'file.notScene': '이 PNG에는 Sketchover 캔버스가 없습니다', + 'file.failed': '파일을 처리할 수 없습니다', 'mode.draw': '✏ 그리는 중', 'mode.pass': '👆 클릭 통과', 'settings.language': '언어', diff --git a/src/i18n/pt.js b/src/i18n/pt.js index 092fac9..ce6313a 100644 --- a/src/i18n/pt.js +++ b/src/i18n/pt.js @@ -33,6 +33,18 @@ export default { 'shot.hint': 'Arraste para escolher a área · Esc para cancelar', 'shot.failed': 'Não foi possível capturar a tela', 'help.shot': 'Captura de uma área', + 'toolbar.copy': 'Copiar imagem (Ctrl+Shift+C)', + 'toolbar.save': 'Salvar PNG (Ctrl+S)', + 'toolbar.open': 'Abrir PNG (Ctrl+O)', + 'help.copy': 'Copiar imagem', + 'help.save': 'Salvar / abrir', + 'file.copied': 'Imagem copiada — cole no chat', + 'file.copyFailed': 'Não foi possível copiar para a área de transferência', + 'file.saved': 'Salvo: {name}', + 'file.opened': 'Tela aberta', + 'file.empty': 'A tela está vazia', + 'file.notScene': 'Este PNG não contém uma tela do Sketchover', + 'file.failed': 'Não foi possível trabalhar com o arquivo', 'mode.draw': '✏ Desenhando', 'mode.pass': '👆 Cliques através', 'settings.language': 'Idioma', diff --git a/src/i18n/ru.js b/src/i18n/ru.js index 38a57f9..c4f4d83 100644 --- a/src/i18n/ru.js +++ b/src/i18n/ru.js @@ -33,6 +33,18 @@ export default { 'shot.hint': 'Выделите область мышью · Esc — отмена', 'shot.failed': 'Не удалось снять экран', 'help.shot': 'Скриншот области', + 'toolbar.copy': 'Копировать картинку (Ctrl+Shift+C)', + 'toolbar.save': 'Сохранить PNG (Ctrl+S)', + 'toolbar.open': 'Открыть PNG (Ctrl+O)', + 'help.copy': 'Картинку в буфер', + 'help.save': 'Сохранить / открыть', + 'file.copied': 'Картинка в буфере — вставьте в чат', + 'file.copyFailed': 'Не удалось скопировать в буфер', + 'file.saved': 'Сохранено: {name}', + 'file.opened': 'Холст открыт', + 'file.empty': 'Холст пуст', + 'file.notScene': 'В этом PNG нет холста Sketchover', + 'file.failed': 'Не удалось работать с файлом', 'mode.draw': '✏ Рисую', 'mode.pass': '👆 Сквозные клики', 'settings.language': 'Язык', diff --git a/src/i18n/zh.js b/src/i18n/zh.js index cd1d1b9..078e15e 100644 --- a/src/i18n/zh.js +++ b/src/i18n/zh.js @@ -33,6 +33,18 @@ export default { 'shot.hint': '拖动鼠标选择区域 · Esc 取消', 'shot.failed': '无法截取屏幕', 'help.shot': '区域截图', + 'toolbar.copy': '复制图片 (Ctrl+Shift+C)', + 'toolbar.save': '保存 PNG (Ctrl+S)', + 'toolbar.open': '打开 PNG (Ctrl+O)', + 'help.copy': '复制图片', + 'help.save': '保存 / 打开', + 'file.copied': '图片已复制,可粘贴到聊天中', + 'file.copyFailed': '无法复制到剪贴板', + 'file.saved': '已保存:{name}', + 'file.opened': '画布已打开', + 'file.empty': '画布为空', + 'file.notScene': '此 PNG 中没有 Sketchover 画布', + 'file.failed': '无法处理该文件', 'mode.draw': '✏ 绘图中', 'mode.pass': '👆 点击穿透', 'settings.language': '语言', diff --git a/src/index.html b/src/index.html index 5d1971d..a6f6589 100644 --- a/src/index.html +++ b/src/index.html @@ -16,6 +16,9 @@
+ + +
diff --git a/src/ui/api.js b/src/ui/api.js index cbcf603..1a6783f 100644 --- a/src/ui/api.js +++ b/src/ui/api.js @@ -15,5 +15,9 @@ export const api = { checkUpdate: () => invoke('check_update'), installUpdate: () => invoke('install_update'), captureScreen: () => invoke('capture_screen'), + // Байты PNG — телом запроса (без JSON); имя — подсказка для диалога, в заголовке только ASCII. + savePng: (bytes, name) => invoke('save_png', bytes, { headers: { 'x-file-name': encodeURIComponent(name) } }), + openPng: () => invoke('open_png'), + copyImage: (rgba, width, height) => invoke('copy_image', rgba, { headers: { 'x-width': String(width), 'x-height': String(height) } }), on: (event, handler) => listen(event, (e) => handler(e.payload)), }; diff --git a/src/ui/export.js b/src/ui/export.js new file mode 100644 index 0000000..392e06c --- /dev/null +++ b/src/ui/export.js @@ -0,0 +1,158 @@ +// Холст как картинка: в буфер обмена (вставить в чат созвона) и в файл PNG со сценой внутри — +// открывается любым просмотрщиком и снова редактируется в Sketchover. +import { state, reserveIds } from '../core/state.js'; +import { bounds } from '../core/shapes.js'; +import { commit } from '../core/history.js'; +import { addImage, getImage } from '../core/images.js'; +import { withTextChunk, readTextChunk } from '../core/png.js'; +import { packScene, unpackScene, SCENE_KEY } from '../core/scene.js'; +import { t } from '../i18n/index.js'; +import { api } from './api.js'; +import { render } from './canvas.js'; +import { finishEdit } from './editor.js'; +import { drawShape } from './shapes-draw.js'; +import { showToast } from './toast.js'; + +// Поля вокруг рисунка: толщина линий, наконечники стрелок, тени скриншотов. +const MARGIN = 24; + +function sceneBox(shapes) { + let x1 = Infinity; + let y1 = Infinity; + let x2 = -Infinity; + let y2 = -Infinity; + for (const s of shapes) { + const b = bounds(s); + const pad = MARGIN + (s.width || 0) * 3; + x1 = Math.min(x1, b.x - pad); + y1 = Math.min(y1, b.y - pad); + x2 = Math.max(x2, b.x + b.w + pad); + y2 = Math.max(y2, b.y + b.h + pad); + } + return shapes.length ? { x: x1, y: y1, w: x2 - x1, h: y2 - y1 } : null; +} + +// Рисунок без фона (прозрачный PNG) в разрешении экрана — чётко и на HiDPI. +function renderScene() { + const box = sceneBox(state.shapes); + if (!box) return null; + const scale = Math.max(1, window.devicePixelRatio || 1); + const canvas = document.createElement('canvas'); + canvas.width = Math.ceil(box.w * scale); + canvas.height = Math.ceil(box.h * scale); + const c = canvas.getContext('2d'); + c.scale(scale, scale); + c.translate(-box.x, -box.y); + for (const s of state.shapes) drawShape(c, s); + return canvas; +} + +const toPngBlob = (canvas) => new Promise((resolve, reject) => { + canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob'))), 'image/png'); +}); +const toPngBytes = async (canvas) => new Uint8Array(await (await toPngBlob(canvas)).arrayBuffer()); + +function toBase64(bytes) { + let binary = ''; + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(binary); +} +const fromBase64 = (text) => Uint8Array.from(atob(text), (ch) => ch.charCodeAt(0)); + +async function imageToBase64(bitmap) { + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d').drawImage(bitmap, 0, 0); + return toBase64(await toPngBytes(canvas)); +} + +function defaultFileName() { + const d = new Date(); + const p = (n) => String(n).padStart(2, '0'); + return `Sketchover ${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}-${p(d.getMinutes())}.png`; +} + +export async function copyImage() { + finishEdit(); + const canvas = renderScene(); + if (!canvas) { + showToast(t('file.empty')); + return; + } + try { + // Через Rust, а не Web Clipboard API: тот зависит от фокуса и «жеста пользователя» + // и по-разному ведёт себя в WebView разных систем. + const { data } = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height); + await api.copyImage(new Uint8Array(data.buffer), canvas.width, canvas.height); + showToast(t('file.copied')); + } catch (e) { + console.error(e); + showToast(t('file.copyFailed')); + } +} + +export async function saveScene() { + finishEdit(); + const canvas = renderScene(); + if (!canvas) { + showToast(t('file.empty')); + return; + } + try { + const images = {}; + for (const s of state.shapes) { + const bitmap = s.type === 'image' && !images[s.imageId] && getImage(s.imageId); + if (bitmap) images[s.imageId] = await imageToBase64(bitmap); + } + const png = withTextChunk(await toPngBytes(canvas), SCENE_KEY, packScene(state.shapes, images)); + const name = await api.savePng(png, defaultFileName()); + if (name) showToast(t('file.saved', { name })); + } catch (e) { + console.error(e); + showToast(t('file.failed')); + } +} + +// Картинки сцены → в хранилище; скриншот, чью картинку не удалось прочитать, выкидываем. +async function loadImages(shapes, images) { + const ids = new Map(); + for (const s of shapes) { + if (s.type !== 'image' || ids.has(s.imageId)) continue; + try { + const blob = new Blob([fromBase64(images[s.imageId])], { type: 'image/png' }); + ids.set(s.imageId, addImage(await createImageBitmap(blob))); + } catch { + ids.set(s.imageId, null); + } + } + return shapes.filter((s) => { + if (s.type !== 'image') return true; + s.imageId = ids.get(s.imageId); + return Boolean(s.imageId); + }); +} + +// Открытый файл заменяет холст; прежний возвращается через Ctrl+Z. +export async function openScene() { + finishEdit(); + try { + const bytes = new Uint8Array(await api.openPng()); + if (!bytes.length) return; // диалог закрыли + const json = readTextChunk(bytes, SCENE_KEY); + const scene = json && unpackScene(json); + if (!scene) { + showToast(t('file.notScene')); + return; + } + state.shapes = await loadImages(scene.shapes, scene.images); + state.sel = null; + reserveIds(Math.max(0, ...state.shapes.map((s) => s.id))); + commit(); + render(); + showToast(t('file.opened')); + } catch (e) { + console.error(e); + showToast(t('file.failed')); + } +} diff --git a/src/ui/help.js b/src/ui/help.js index c80c72d..bbdd59e 100644 --- a/src/ui/help.js +++ b/src/ui/help.js @@ -48,6 +48,8 @@ export function renderHelp() { aside.append(row(`${formatKeys('Alt')} + 1…${COLORS.length}`, t('help.colors'))); aside.append(row(`${formatKeys('Shift')} + 1…${WIDTHS.length}`, t('help.widths'))); aside.append(row(`${formatKeys('CommandOrControl')} + Z / Y`, t('help.undo'))); + aside.append(row(`${formatKeys('CommandOrControl')} + ${formatKeys('Shift')} + C`, t('help.copy'))); + aside.append(row(`${formatKeys('CommandOrControl')} + S / O`, t('help.save'))); aside.append(row('Del / Shift + Del', t('help.delete'))); } diff --git a/src/ui/keyboard.js b/src/ui/keyboard.js index dbfc5f7..1c15e82 100644 --- a/src/ui/keyboard.js +++ b/src/ui/keyboard.js @@ -5,6 +5,7 @@ import { undo, redo, deleteSelected, clearAll } from './actions.js'; import { selectTool, applyStyle } from './toolbar.js'; import { toggleHelp } from './help.js'; import { takeScreenshot } from './screenshot.js'; +import { copyImage, saveScene, openScene } from './export.js'; // Цифры берём по физической клавише (e.code): работает на любой раскладке и с Alt/Shift // (Shift+1 даёт «!», Option+1 на macOS — «¡»). @@ -28,6 +29,9 @@ export function initKeyboard(tools) { if (mod && e.code === 'KeyZ') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; } if (mod && e.code === 'KeyY') { e.preventDefault(); redo(); return; } + if (mod && e.shiftKey && e.code === 'KeyC') { e.preventDefault(); copyImage(); return; } + if (mod && e.code === 'KeyS') { e.preventDefault(); saveScene(); return; } + if (mod && e.code === 'KeyO') { e.preventDefault(); openScene(); return; } if (mod) return; if (idx >= 0) { diff --git a/src/ui/toolbar.js b/src/ui/toolbar.js index a12c066..ef1d240 100644 --- a/src/ui/toolbar.js +++ b/src/ui/toolbar.js @@ -9,7 +9,11 @@ import { setMode, toggleMode, relabelMode, syncPassThrough } from './mode.js'; import { undo, redo, clearAll } from './actions.js'; import { toolIcon } from './icons.js'; import { takeScreenshot } from './screenshot.js'; +import { copyImage, saveScene, openScene } from './export.js'; +const COPY = 'M8 3h9a2 2 0 0 1 2 2v11h-2V5H8zM5 7h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2zm0 2v10h9V9z'; +const SAVE = 'M5 3h11l5 5v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm2 2v4h8V5zm5 8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z'; +const OPEN = 'M3 6a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'; const CAMERA = 'M9 4 7.5 6H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-3.5L15 4zm3 4.5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9zm0 2a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z'; const $ = (s) => document.querySelector(s); @@ -110,6 +114,10 @@ export function initToolbar(toolList) { enableDrag(); $('#shot-btn').append(toolIcon({ iconPath: CAMERA })); $('#shot-btn').addEventListener('click', takeScreenshot); + for (const [id, path, action] of [['#copy-btn', COPY, copyImage], ['#save-btn', SAVE, saveScene], ['#open-btn', OPEN, openScene]]) { + $(id).append(toolIcon({ iconPath: path })); + $(id).addEventListener('click', action); + } $('#undo').addEventListener('click', undo); $('#redo').addEventListener('click', redo); $('#clear').addEventListener('click', clearAll); diff --git a/tests/core.test.js b/tests/core.test.js index 72657cb..cf3fff6 100644 --- a/tests/core.test.js +++ b/tests/core.test.js @@ -120,3 +120,49 @@ test('картинки: освобождаются только те, на ко assert.deepEqual(closed, ['dropped']); assert.equal(imageCount(), 1); }); + +test('png: чанк со сценой вставляется и читается, кириллица цела', async () => { + const { withTextChunk, readTextChunk } = await import('../src/core/png.js'); + // Минимальный PNG 1×1 (прозрачный пиксель). + const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0)); + const text = JSON.stringify({ hello: 'Привет, 世界' }); + const out = withTextChunk(png, 'sketchover', text); + assert.equal(readTextChunk(out, 'sketchover'), text); + assert.equal(readTextChunk(out, 'other'), null); + assert.equal(readTextChunk(png, 'sketchover'), null); + // Картинка остаётся корректным PNG: начинается с сигнатуры и заканчивается IEND. + assert.deepEqual([...out.subarray(0, 8)], [...png.subarray(0, 8)]); + assert.equal(String.fromCharCode(...out.subarray(out.length - 8, out.length - 4)), 'IEND'); + assert.throws(() => withTextChunk(new Uint8Array([1, 2, 3]), 'k', 't')); +}); + +test('сцена: упаковка и проверка недоверенного файла', async () => { + const { packScene, unpackScene } = await import('../src/core/scene.js'); + const node = { id: 1, type: 'node', color: '#ff3b30', width: 3, x: 0, y: 0, w: 100, h: 50, size: 18, text: '**Да**' }; + const arrow = { id: 2, type: 'arrow', color: '#ffffff', width: 3, x1: 0, y1: 0, x2: 50, y2: 50, from: 1, to: 99 }; + const image = { id: 3, type: 'image', color: '#ffffff', width: 3, x: 0, y: 0, w: 10, h: 10, rotation: 0, imageId: 'img1' }; + const orphan = { ...image, id: 4, imageId: 'missing' }; + const bad = [{ id: 5, type: 'script', color: '#000000', width: 1 }, { id: 6, type: 'rect', color: 'red', width: 1, x: 0, y: 0, w: 1, h: 1 }, { id: 7, type: 'rect', color: '#000000', width: 1, x: 'x', y: 0, w: 1, h: 1 }]; + const scene = unpackScene(packScene([node, arrow, image, orphan, ...bad], { img1: 'AAAA' })); + assert.deepEqual(scene.shapes.map((s) => s.id), [1, 2, 3]); + assert.equal(scene.shapes[1].from, 1); + assert.equal(scene.shapes[1].to, null, 'ссылка на несуществующую фигуру снята'); + assert.equal(unpackScene('{"app":"other","shapes":[]}'), null); + assert.equal(unpackScene('не JSON'), null); +}); + +test('история: объём ограничен, но последний шаг отмены всегда есть', () => { + reset(); + commit(); + // Две огромные «кисти» по ~30 млн символов: вместе больше лимита 50 млн. + const big = (n) => ({ ...createShape('rect', 0, 0), id: 1000 + n, text: 'x'.repeat(30_000_000) }); + state.shapes = [big(1)]; + commit(); + state.shapes = [big(2)]; + commit(); + state.shapes = []; + commit(); + assert.equal(undo(), true); + assert.equal(state.shapes[0].id, 1002, 'последнее состояние восстанавливается'); + assert.equal(undo(), false, 'старое тяжёлое состояние вытеснено лимитом объёма'); +}); From 26c681d3e98c199721c58bc087c34855de8ab7c3 Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 11:07:07 +0300 Subject: [PATCH 10/12] =?UTF-8?q?docs:=20CHANGELOG=20=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=B8=D0=B7=20=D0=BD=D0=B5=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раздел версии из CHANGELOG.md становится описанием черновика релиза; без раздела релиз не выпускается. Заметки к 0.1.0. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 6 ++++-- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ CLAUDE.md | 2 +- README.md | 2 +- README.ru.md | 2 +- 5 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 555c383..88b30e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,9 +53,11 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Заметки — раздел версии из CHANGELOG.md; без раздела релиз не выпускаем. + awk -v v="${TAG#v}" 'index($0, "## [" v "]") == 1 { on = 1; next } on && /^## \[/ { exit } on' CHANGELOG.md > notes.md + test -s notes.md || { echo "В CHANGELOG.md нет раздела ## [${TAG#v}]"; exit 1; } gh release view "$TAG" > /dev/null 2>&1 || gh release create "$TAG" --draft --verify-tag \ - --title "Sketchover ${TAG#v}" \ - --notes "Установщики: Windows — .exe, macOS — .dmg (Intel и Apple Silicon), Linux — .AppImage, .deb или .rpm." + --title "Sketchover ${TAG#v}" --notes-file notes.md echo "id=$(gh release view "$TAG" --json databaseId -q .databaseId)" >> "$GITHUB_OUTPUT" build: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bf19bc1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to Sketchover. The release workflow takes the notes for each version from this file. + +## [0.1.0] — first release + +Draw on your screen during calls — a free screen annotation tool for Windows, macOS and Linux. + +### Drawing +- Brush, line, arrow, rectangle, ellipse, text, text blocks, laser pointer and eraser over every window. +- Text blocks and text with Markdown: headings, bullet and numbered lists that continue on `Enter`, **bold**, *italic*, `code`, ~~strikethrough~~. Double-click to edit with any tool. +- Arrows attach to text blocks and screenshots and follow them. +- Region screenshots (`S`): select part of the screen and it lands on the canvas — move, resize, rotate. + +### Calls +- One global hotkey, `` Alt+` `` (configurable), opens the canvas on the monitor under the cursor. +- Hold `Alt` to click and scroll the windows below; double-tap `Alt` to stay in click-through mode (Windows, macOS). +- `Ctrl+Shift+C` copies the drawing as an image — paste it into the call chat. +- `Ctrl+S` saves a PNG that opens anywhere and keeps the editable canvas inside; `Ctrl+O` opens it again. + +### App +- Starts with the system and waits in the tray; closing the canvas only hides it. +- Keys work on any keyboard layout: `1…0` tools, `Alt+1…8` colors, `Shift+1…3` stroke width, `F1` cheat sheet. +- Settings panel: language, hotkey, start with the system, updates, about. +- 11 languages: English, Русский, Deutsch, Español, Français, Português, 中文, 日本語, 한국어, العربية, हिन्दी. +- Signed automatic updates through ProAnima Hub. + +### Install +- Windows 10/11 — `.exe`, no admin rights needed. +- macOS 11+ — one `.dmg` for Intel and Apple Silicon. +- Linux (Ubuntu 24.04+, Debian 13+, Fedora 40+) — AppImage, `.deb`, `.rpm`. + +The installers are not yet signed with a paid developer certificate: on first launch Windows may show “Windows protected your PC” (More info → Run anyway), and macOS may ask you to open the app with right-click → Open. diff --git a/CLAUDE.md b/CLAUDE.md index 6efcf42..d003c31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ src/i18n/ словари; используется только из ui - Состояние режима (рисование / сквозные клики) всегда явно видно на экране. - Панель не должна перекрывать работу и должна оставаться кликабельной в сквозном режиме. - Настройки и «О программе» — одна компактная раскрываемая панель, не отдельные окна. -- Все тексты интерфейса — через `t(key)`, 11 языков как в Signal Lab; новый ключ добавляется во все словари. README.md — на английском (поиск), README.ru.md — полная русская версия, остальные документы — на русском; изменил поведение или горячую клавишу — обнови оба README. +- Все тексты интерфейса — через `t(key)`, 11 языков как в Signal Lab; новый ключ добавляется во все словари. README.md и CHANGELOG.md — на английском (поиск, страница релиза), README.ru.md — полная русская версия, остальные документы — на русском; новая версия — раздел в CHANGELOG.md (из него берутся заметки релиза); изменил поведение или горячую клавишу — обнови оба README. ## Стиль кода - JS: ES-модули, `const` по умолчанию, `let` при необходимости, без `var`. diff --git a/README.md b/README.md index 7a01f4a..58f7638 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ npm run check # interface checks npm test # core logic tests ``` -Releases: `npm version patch && git push --follow-tags` — GitHub Actions builds the installers for all three systems into a draft release. +Releases: add a `## [X.Y.Z]` section to [CHANGELOG.md](CHANGELOG.md), then `npm version X.Y.Z && git push --follow-tags` — GitHub Actions builds the installers for all three systems into a draft release with those notes. Tauri 2 (Rust) for the system side, plain JavaScript modules for the canvas, no bundler. Project rules: [CLAUDE.md](CLAUDE.md), roadmap: [docs/ROADMAP.md](docs/ROADMAP.md). diff --git a/README.ru.md b/README.ru.md index 8a3c361..85065d0 100644 --- a/README.ru.md +++ b/README.ru.md @@ -76,7 +76,7 @@ npm start npm version patch ``` -`npm version` (`patch`, `minor` или `major`) поднимает версию в `package.json` и `src-tauri/Cargo.toml`, коммитит и ставит тег `vX.Y.Z`. После `git push --follow-tags` GitHub Actions (`.github/workflows/release.yml`) проверяет код, собирает установщики для Windows, macOS и Linux, подписывает их для автообновления и кладёт в **черновик** релиза вместе с `latest.json`. Опубликовать черновик — вручную, после проверки; с публикацией версию видят ProAnima Hub и установленные приложения. +Сначала — раздел `## [X.Y.Z]` в [CHANGELOG.md](CHANGELOG.md): из него берутся заметки релиза. `npm version` (`patch`, `minor`, `major` или номер) поднимает версию в `package.json` и `src-tauri/Cargo.toml`, коммитит и ставит тег `vX.Y.Z`. После `git push --follow-tags` GitHub Actions (`.github/workflows/release.yml`) проверяет код, собирает установщики для Windows, macOS и Linux, подписывает их для автообновления и кладёт в **черновик** релиза вместе с `latest.json`. Опубликовать черновик — вручную, после проверки; с публикацией версию видят ProAnima Hub и установленные приложения. Секреты репозитория: `TAURI_SIGNING_PRIVATE_KEY` и `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — ключ подписи обновлений (публичная половина — в `src-tauri/tauri.conf.json`). From c4ed881e930508ca510ca6f5de361a3e03f244f7 Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 11:11:43 +0300 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20=D0=BB=D0=B0=D0=B7=D0=B5=D1=80=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=88=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=85=20=D1=81=20=D1=83=D0=B3=D0=B0=D1=81=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=BC=20=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все точки лазера шли одним списком, и первая точка нового штриха соединялась с хвостом прошлого — на пару кадров линия через весь экран. Теперь начало штриха помечено, и между штрихами отрезка нет. Co-Authored-By: Claude Opus 5.5 --- src/tools/laser-tool.js | 5 +++-- src/ui/canvas.js | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tools/laser-tool.js b/src/tools/laser-tool.js index eebb559..689b30c 100644 --- a/src/tools/laser-tool.js +++ b/src/tools/laser-tool.js @@ -1,10 +1,11 @@ import { state } from '../core/state.js'; export function createLaserTool(meta, host) { - const point = (x, y) => state.laser.points.push({ x, y, t: performance.now() }); + // start — первая точка штриха: с предыдущим (ещё угасающим) штрихом её не соединяем. + const point = (x, y, start = false) => state.laser.points.push({ x, y, t: performance.now(), start }); return { ...meta, - down(x, y) { state.laser.active = true; point(x, y); host.render(); }, + down(x, y) { state.laser.active = true; point(x, y, true); host.render(); }, move(x, y) { if (state.laser.active) { point(x, y); host.render(); } }, up() { state.laser.active = false; }, }; diff --git a/src/ui/canvas.js b/src/ui/canvas.js index a2f4b1e..0c7ae1f 100644 --- a/src/ui/canvas.js +++ b/src/ui/canvas.js @@ -48,6 +48,8 @@ function drawLaser(now) { const pts = state.laser.points; while (pts.length && now - pts[0].t > LASER_LIFETIME_MS) pts.shift(); for (let i = 1; i < pts.length; i++) { + // Новый штрих начинается с чистого места — без линии от хвоста прошлого через весь экран. + if (pts[i].start) continue; const age = (now - pts[i].t) / LASER_LIFETIME_MS; ctx.save(); ctx.strokeStyle = `rgba(255,59,48,${1 - age})`; From 68cbdbe6dca4f26b96060f3c5e4dda8f0146d838 Mon Sep 17 00:00:00 2001 From: Feargin Date: Fri, 9 Oct 2026 11:11:43 +0300 Subject: [PATCH 12/12] =?UTF-8?q?chore:=20=D1=81=D0=BE=D0=B1=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=20Sketchover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо общего с Signal Lab: утечка одного ключа не затронет другое приложение. Приватная половина — только в секретах CI. Co-Authored-By: Claude Opus 5.5 --- docs/ROADMAP.md | 2 +- src-tauri/tauri.conf.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 195207a..cdf7cbf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -75,7 +75,7 @@ ### Обновления через ProAnima Hub - `tauri-plugin-updater`: хаб первым, GitHub Releases запасным — `https://hub.proanima.net/v1/sketchover/update/{{target}}/{{arch}}/{{current_version}}`. - Случайный `install_id` (UUID) в заголовке `X-Install-Id` — для поэтапной раскатки. Больше ничего в сеть не уходит. -- Подпись: ключ minisign студии (публичный — в `tauri.conf.json`, приватный — только в секретах CI). +- Подпись: собственный ключ minisign Sketchover (не общий с Signal Lab — утечка одного не затронет другое). Публичный — в `tauri.conf.json`, приватный и пароль — только в секретах CI (`TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`) и у владельца в `~/.tauri/sketchover-updater.*`. ## Порядок работ diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index effde5a..c55ae47 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,7 @@ }, "plugins": { "updater": { - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEExMzVGMERBQTAzNTY3RDQKUldUVVp6V2cydkExb1htSTNMeDd4NVhOSDErSlZRVDRyTHFYM2JCODlTazlsSEZuVjh0MHcwQVMK", + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE1MDA5Mzg2ODA4Mzg3NjkKUldScGg0T0FocE1BcGRvU1BTM0k3Q1BoZXZOb0NmTktwb3U2MTFpNWREQ3owWEpvRndtSDk4VTgK", "endpoints": [ "https://hub.proanima.net/v1/sketchover/update/{{target}}/{{arch}}/{{current_version}}", "https://github.com/ProAnima/Sketchover/releases/latest/download/latest.json"