Я хотел заметочник, где заметки — обычные .md-файлы в обычной папке, есть [[ссылки]] с обратными ссылками и поиск, а текст сразу выглядит оформленным, без отдельной панели предпросмотра. И чтобы он запускался быстро, не тащил в память целый браузер и не требовал аккаунта. Готовые варианты почти все построены на WebView или Electron. Я написал свой: Leaflet, C# и Avalonia, без единой веб-страницы внутри. Расскажу, как он устроен и где пришлось повозиться.

Что хотелось получить
-
Заметки — файлы
.mdв любой папке; программа ничего не прячет в базу. -
[[Имя]],[[Имя#Раздел]],[[Имя|подпись]], обратные ссылки, теги#проект/сайт, поиск по всем заметкам. -
Живой просмотр прямо в редакторе: строка с курсором показывает исходный Markdown, остальные — оформленный текст.
-
Экспорт в HTML и PDF.
-
Папка с exe, которая работает на чистой Windows 10/11 без установки.
Как это устроено
Хранилище. Vault читает все .md в выбранной папке (скрытые .папки пропускает), строит индекс ссылок и следит за папкой через слежение за файлами. Запись идёт через временный файл, чтобы сбой посреди записи не оставил половину заметки. При переименовании или переносе заметки Vault.Move правит [[ссылки]] во всех остальных заметках, сохраняя #раздел и |подпись; объект заметки при этом остаётся тем же, поэтому открытые вкладки и история не рвутся.
Редактор. Основа — AvaloniaEdit, порт AvalonEdit. Живой просмотр сделан не заменой документа, а оформлением строк поверх исходного текста: генераторы элементов и трансформеры скрывают разметку и красят куски на всех строках, кроме той, где стоит каретка. Текст файла при этом всегда остаётся исходным Markdown, и отмена, поиск и выделение работают как в обычном редакторе.
Из этого получаются остальные возможности:
-
таблица — одна строка редактора превращается в один элемент с ячейками, сетку рисует отдельный рендерер; строка с курсором показывает исходник, а её
|выравниваются по линиям сетки (второй проход поVisualLinesChanged); -
список — маркер с отступом становится одним элементом, уровень считается по отступу, нумерация остаётся текстом;
- [ ]и- [x]показываются флажками, щелчок меняет один знак и создаёт свой шаг отмены; -
картинка
— после строки ставится ряд с изображением, которое декодируется в фоне сразу в нужную ширину; -
встраивание
![[Имя]]— карточка с содержимым другой заметки, глубина вложенности не больше четырёх, круговое встраивание обнаруживается.
Почему Avalonia, а не WebView
WebView или Electron дали бы готовый Markdown-рендер за вечер. Но вместе с ним — второй процесс с тяжёлой памятью, заметная задержка старта и вопросы по обновлению среды. Мне нужен был маленький «родной» процесс. Avalonia 12 на .NET 8 рисует через Skia, работает на Windows 10/11 и позволяет ограничиться одним процессом. Побочный плюс: PDF тоже собирается без браузера, через Skia (ниже).
Цена: интерфейс пришлось писать кодом. Установленный на машине SDK 8.0.403 старше Roslyn 4.14, из-за чего XAML не компилировался (ошибка CS9057), поэтому весь UI написан кодом на C#.
Что получилось по замерам на заметке в 5 013 строк (216 тыс. символов), окно 1200×780 с панелями, сборка ReadyToRun:
-
до первого кадра 0,66–0,89 с;
-
рабочий набор после загрузки около 133 МБ;
-
обработка символа (ввод + раскладка) в среднем 0,4 мс;
-
Enter в среднем 3 мс.
Это мои замеры на одной машине, для ориентира, не бенчмарк для сравнения.
Подводные камни
Окно без белой полосы. Своя шапка с вкладками и кнопками окна нужна, чтобы не терять место. Но с полным режимом декораций Avalonia 12 рисует собственный заголовок поверх вкладок, а без расширения клиентской области на Windows 10 сверху видна белая полоса в 7 пикселей. Рабочая комбинация такая:
WindowDecorations = WindowDecorations.BorderOnly;ExtendClientAreaToDecorationsHint = true;
Свои кнопки окна — отдельно. Я проверил перетаскивание, двойной щелчок, края и углы, разворот ровно по рабочей области, сворачивание и закрытие.
Editor.Focus() ничего не делает. TextEditor не принимает фокус сам: метод возвращает false, а фокус остаётся нигде. Фокус в редактор ставится через Editor.TextArea.Focus(). Похожая мелочь: если скрыть панель, в которой стоит фокус, сначала нужно перевести фокус в редактор и только потом делать IsVisible = false.
Свёрнутая строка таблицы. Разделитель |---| скрывается, чтобы строка не оказывалась ниже шрифта (CollapseLines). Но свёрнутую строку обязан покрыть элемент предыдущей строки (шапка тянется до конца разделителя), иначе AvaloniaEdit падает. Узнаётся только экспериментом.
Каретка рядом с картинкой. Без метки нулевой ширины в конце строки каретка рисуется на ряду картинки во всю высоту. Пришлось ставить такую метку и отдельно обрабатывать стрелки: вниз — на следующую строку, вверх или щелчок — в конец текста, чтобы каретка не стояла в ряду картинки.
Скорость при вводе. Карта блоков (текст, код, строки таблицы) пересобирается только при вводе или удалении `, |, перевода строки, а - и : — если в строке есть |. Проход по doc.Text как по строке оказался в разы дешевле обхода doc.Lines через GetCharAt: на 5 000 строк разница была около 1,2 мс на каждый Enter.
PDF без браузера. Экспорт делает SKDocument.CreatePdf из SkiaSharp, он уже входит в Avalonia, поэтому WebView не нужен. Вёрстка своя: A4, цвета выбранной темы, перенос по словам, номера страниц, ссылки аннотациями. Но сборка SkiaSharp идёт без урезателя шрифтов и встраивает их целиком: одна страница весила 1,6 МБ. Пришлось писать свой FontSubset: номера глифов остаются прежними, ненужные контуры становятся пустыми, делается свой cmap и убираются таблицы GSUB и GPOS. Страница стала около 130 КБ. Шрифты CFF (OTTO) не урезаются.
Данные важнее красоты. Программа лежит в папке с моими настоящими заметками, поэтому над надёжностью пришлось поработать:
-
файл изменили снаружи, пока в программе есть несохранённые правки: вкладка получает состояние «конфликт» и полосу с вариантами («Оставить мою», «Взять с диска», «Сохранить мою как копию», «Сравнить»); перед записью дата файла сверяется с той, что программа видела, чтобы не затереть чужую правку, если слежение не успело сработать;
-
черновики пишутся в фоне через 1,5 с после правки и удаляются при сохранении; после настоящего сбоя программа предложит их восстановить;
-
снимки версий с разницей по строкам (алгоритм Майерса), с пределом 50 на заметку и 100 МБ в сумме, возврат к версии делается правкой, так что Ctrl+Z его тоже отменяет;
-
заметку переименовали в проводнике: если пропала одна и в той же пачке появилась другая с тем же текстом, считаем это переименованием, и вкладка идёт за файлом.
Для проверки сбоя самопроверка запускает второй экземпляр программы, правит без автосохранения, ждёт появления черновика и убивает процесс через Process.Kill(). Потом родитель проверяет файл и восстановление.
Один экземпляр. Мьютекс с именем Local\Leaflet.Mutex.<хэш папки данных> плюс именованный канал: второй запуск передаёт первому пути и ждёт «ok», а первое окно выходит вперёд. Хэш папки данных в имени нужен, чтобы портативная копия, обычная и тесты не мешали друг другу.
Положение окна на нескольких мониторах. Размеры хранятся в логических единицах, положение в пикселях, плюс масштаб монитора. При запуске список мониторов (EnumDisplayMonitors и GetDpiForMonitor) помогает выбрать тот, с которым окно пересекается больше всего, сдвинуть его внутрь рабочей области и убедиться, что полоса заголовка не меньше 100 пикселей. Отдельно — окно, закрытое в развёрнутом виде: тогда в момент закрытия Avalonia отдаёт границы монитора, а не обычные.
Масштаб Windows. Масштаб на моей машине не менялся, поэтому самопроверка шлёт окну настоящий WM_DPICHANGED и проверяет: Avalonia пересчитала масштаб, логический размер окна сохранился, границы панелей остались на целых пикселях. Живой перенос между мониторами с разным масштабом на настоящем железе я не проверял — это честное ограничение.
Сборка и поставка
Основной zip — self-contained: работает на чистой Windows без .NET. Размер папки 115 МБ, zip около 50 МБ, против 44 и 18 МБ у варианта, которому нужен установленный .NET 8 Runtime. Запуск и память при этом одинаковые, так что разницы по скорости нет, выбираем удобство.
dotnet publish src/Leaflet -c Release -r win-x64 --self-contained true -p:PublishReadyToRun=true
Single-file и обрезку (trim) я не делал: Avalonia и AvaloniaEdit используют рефлексию, а выигрыш в размере не стоит риска. Из поставки убраны *.pdb: у SkiaSharp и HarfBuzz они весят около 100 МБ. В конце build.ps1 распаковывает zip в чистую временную папку, запускает exe с отдельной папкой данных и ждёт появления окна.
Программа не подписана цифровой подписью, поэтому SmartScreen может показать синее окно: «Подробнее» → «Выполнить в любом случае».
Что получилось
Три темы («Графит», «Терминал» и «Светлая»), интерфейс на русском и английском, настраиваемые горячие клавиши, палитра команд, встроенная справка.


Проект проверяется собственной самопроверкой на 384 проверки. Она запускает настоящее окно и шлёт настоящие нажатия клавиш и мыши (Ctrl+F, набор [[, перетаскивание файлов, Ctrl+V с картинкой в буфере), поэтому на несколько минут берёт клавиатуру и мышь на себя и запускается только на копии тестовой папки.
Чего нет: плагинов, синхронизации, мобильных версий. Это заметочник «папка с файлами», остальное можно сделать поверх своими средствами (git, облачная папка).
Ссылки
-
Страница проекта и исходный код (MIT): https://github.com/OlegChe77/leaflet
-
Готовый zip (около 50 МБ, Windows 10/11, ничего ставить не нужно): https://github.com/OlegChe77/leaflet/releases/latest/download/Leaflet.zip
Проект бесплатный. Предложения и найденные ошибки — в комментариях или в Issues на GitHub.
ссылка на оригинал статьи https://habr.com/ru/articles/1101886/