javascript

Пишем плагины для Obsidian. Часть 2

  • пятница, 29 ноября 2024 г. в 00:00:03
https://habr.com/ru/articles/862166/

Продолжаем писать собственные плагины для Obsidian. Первую часть статьи можете найти здесь. В ней мы:

  • Выяснили, что можно писать плагины даже проще, чем это предлагает делать официальная документация

  • Написали три маленьких плагина, которые уже вовсю используются на продакшене лично мной

  • Грозились написать четвертый финальный босс-плагин

Вот и приступим.

Плагин 4. Chess Viewer. Идея

Я не шахматист, но интересуюсь :) У меня есть заметки с шахматными зарисовками. И я определенно не один такой, поскольку в данный момент в Community plugins есть три плагина, позволяющих отображать в заметке шахматную доску с фигурами.

Я в свое время пользовался Obsidian Chess, но он использовал изображения в качестве фигур на доске, и они по какой-то причине очень долго прогружались на странице. Потом картинки фигур и вовсе исчезли:

Теперь я вообще не нахожу этот плагин в списке доступных для установки.

Существует еще пара решений, например, Chesser или Chess Study, но они уже скорее похожи на комбайны — там можно передвигать фигуры, брейнштормить, рисовать интерактивные стрелки и прочее. В общем сложно — мне нужно было что-то простое, надежное и не тормозящее.

Близким по духу является плагин Chessboard Viewer: view-only, фигуры рисуются в svg, есть лаконичные возможности подсветки значимых клеток и отрисовки статичных стрелок. Но у него есть два недостатка:

  • Мне не нравятся его фигуры и доска на вид

  • Я уже придумал свой бредовый способ дешево отображать фигуры, и это не SVG

Что за способ такой? Даже не знаю, как вам сказать — это ASCII-символы :) Вот они: ♔ ♕ ♖ ♗ ♘ ♙ ♚ ♛ ♜ ♝ ♞ ♟. И если их увеличить, то можно увидеть, что они ко всему прочему очень даже неплохи на вид как по мне:

Сначала я хотел отображать доску в аскетичном текстовом ASCII-only варианте. Но после некоторых потугов понял, что у меня будут непоправимые проблемы с отображением пустых белых и черных клеток. Поэтому было принято решение углубиться в нормальную html-css верстку, а в каждой клеточке уже рисовать свои ASCII-символы. Это все еще дешево, и основная фишка — ASCII-фигуры — все еще сохраняется, но зато это не будет выглядеть как дно.

Как описывать доску в заметке

Традиционно в таких плагинах для записи используется FEN-нотация. Это простой способ и доступный способ описать состояние шахматной доски одной строчкой. В базовом варианте FEN нотация выглядит так:

rnbqkbnr/pp1ppppp/2p5/8/4P3/8/PPPP1PPP/RNBQKBNR

Ряды отделены слешами /. Буквы означают: r — ладья, n — конь, b — слон, q — ферзь, k — король, p — пешка. Большие буквы для белых, строчные для черных. Цифры указывают количество пустых клеток в ряду, идущих подряд. Далее у FEN-строки могут быть дополнительные приписки, касающиеся порядка хода, права рокировки и прочее, но они нас в разрезе нашей задачи не интересуют.

Мы хотим чтобы наш плагин работал так: мы в заметке будем писать FEN-нотацию в блоке кода с пометкой chess:

```chess
8/3k1P2/2N5/b7/5K2/4r3/3P4/8
```

а в режиме просмотра заметка должна вместо блока кода отрисовать шахматную доску с фигурами, примерно так:

Приступаем к написанию

Итак, мы хотим найти все chess-блоки в заметке и перезаписать их содержимое. Хм, вам это ничего не напоминает? Если вы читали предыдущую статью, то можете вспомнить, что это один-в-один сценарий нашего первого самого простого плагина: Guitar Tabs Viewer, который подменял "все тире на точки" в погоне за читабельностью гитарной табулатуры. Т.е. как минимум мы можем оттуда подглядеть, что подменой HTML на стадии пререндеринга страницы занимается метод Plugin.registerMarkdownCodeBlockProcessor(), а заготовка плагина должна выглядеть как-то так:

class ChessLightweightPlugin extends obsidian.Plugin {
    async onload() {
        this.registerMarkdownCodeBlockProcessor('chess', (source, el, ctx) => {
            // Render chess board
            el.innerHTML = ...???
        })
    }
}

Остается лишь выяснить, что нужно положить в el.innerHTML, и плагин будет готов! Из очевидного — в el.innerHTML определенно должна лежать HTML-разметка доски: черно-белые клеточки. А в каждую из клеток можно при необходимости текстом записать имеющуюся у нее фигуру.

HTML-скелет

Тут в дело вступает щепотка говнокода, которая еще никому не помешала. Я просто не знаю, как еще описать то, что я сделал в качестве HTML-исполнения пустой доски, крепитесь:

const boardHtml =
`<div class="board">
    <div class="row" id="row8">
        <div class="square white" id="a8"></div>
        <div class="square black" id="b8"></div>
        <div class="square white" id="c8"></div>
        <div class="square black" id="d8"></div>
        <div class="square white" id="e8"></div>
        <div class="square black" id="f8"></div>
        <div class="square white" id="g8"></div>
        <div class="square black" id="h8"></div>
    </div>
    <div class="row" id="row7">
        <div class="square black" id="a7"></div>
        <div class="square white" id="b7"></div>
        <div class="square black" id="c7"></div>
        <div class="square white" id="d7"></div>
        <div class="square black" id="e7"></div>
        <div class="square white" id="f7"></div>
        <div class="square black" id="g7"></div>
        <div class="square white" id="h7"></div>
    </div>
...
    <div class="row" id="row1">
        <div class="square black" id="a1"></div>
        <div class="square white" id="b1"></div>
        <div class="square black" id="c1"></div>
        <div class="square white" id="d1"></div>
        <div class="square black" id="e1"></div>
        <div class="square white" id="f1"></div>
        <div class="square black" id="g1"></div>
        <div class="square white" id="h1"></div>
    </div>
</div>`

Ну вы поняли — мне было лень генерировать это через JS, к тому же я получил какую-то необъяснимую наглядность в этих восьмидесяти с копейками строках — будто на скелет пустой доски смотрю :)

Теперь мы можем перезаписать блок chess-кода этой доской:

async onload() {
	this.registerMarkdownCodeBlockProcessor('chess', (source, el, ctx) => {
		el.innerHTML = boardHtml
	})
}

Вот только даже проверять не нужно, чтобы понять, что мы таким образом заменили наши блоки кода в заметках на визуальное ничего — пустоту, поскольку у наших <div>'ов еще нет визуального воплощения.

CSS

Давайте это исправлять. У меня получился вот такой css-файл, который должен визуализировать доску:

body {
    --cell-size: 50px;
}

.board {
    margin: 20px 0px 20px 0px;
}

.row {
    display: flex;
    flex-direction: row;
}

.square {
    width: var(--cell-size);
    height: var(--cell-size);
    display: flex;
    align-items: center;
    justify-content: center;
}

.white {
    background-color: color(srgb 0.944 0.944 0.944);
}

.black {
    background-color: color(srgb 0.81333 0.81333 0.81333);
}

Ничего примечательного: даем клеткам размеры, красим их в свои цвета, выстраиваем их в ряды.

Я по-началу проверял работоспособность этих стилей через обычную пару файлов html-css в браузере, и оно работало. Но как теперь применить этот css в плагине? Очень просто — вы создаете файл с именем styles.css (именно таким именем!) и просто подкладываете его в папку, где лежит main.js. Ничего дополнительного делать не нужно, стили подхватятся сами.

Делаем проверку на простой заметке:

# Two rooks mate

```chess
8/8/4k3/R7/7R/8/8/6K1
```

```chess
8/4k3/7R/R7/8/8/8/6K1
```

Получаем:

Работает! Я не даром указал в классе .board отступы сверху и снизу: margin: 20px 0px 20px 0px;. Без этого две доски склеиваются в единое полотно.

Доски рисуются, но содержимое chess-блоков игнорируется, а сами доски пусты. Пока что продолжим игнорировать FEN-записи — до этого мы еще дойдем. А сейчас на скорую руку разместим фигуры прямо хардкодом в HTML:

const whitesBoardHtml =
`<div class="board">
    <div class="row" id="row8">
        <div class="square white" id="a8">♞</div>
        <div class="square black" id="b8"></div>
        <div class="square white" id="c8">♗</div>
        <div class="square black" id="d8"></div>
        <div class="square white" id="e8">♛</div>
        <div class="square black" id="f8"></div>
        <div class="square white" id="g8">♔</div>
        <div class="square black" id="h8"></div>
    </div>
    <div class="row" id="row7">
        <div class="square black" id="a7"></div>
        <div class="square white" id="b7">♟</div>
        <div class="square black" id="c7"></div>
        <div class="square white" id="d7">♚</div>
        <div class="square black" id="e7"></div>
        <div class="square white" id="f7">♖</div>
        <div class="square black" id="g7"></div>
        <div class="square white" id="h7">♝</div>
    </div>
...

Смотрим на результат:

Маловато, нужно увеличить размер шрифта. И мне уже виднеется нечто странное... Увеличиваем:

.square {
	...
    font-size: calc(var(--cell-size) * 0.85);
}

смотрим:

Что происходит?! Почему черная пешка такая? Как оказалось в процессе разбирательства, это баг ASCII, а точнее конкретно символа черной пешки. Зависит от того, кто рендерит шрифт, но в 90% случаев с черной пешкой есть проблемы во всех программах — она крупнее остальных и синяя. И в Obsidian тоже. Но при случайных обстоятельствах вам может повезти. Мне вот тоже как-то повезло, и банальное:

.square {
	...
    font-family: serif;
}

исправило ситуацию:

Пешка пришла в норму. Странно, но ладно. Возможно, это работает только в рамках Obsidian. Но работает.

Очерчиваем бизнес-логику

Прежде чем приступить к непосредственной бизнес-логике, ненадолго вернемся к самому плагину. Мы знаем HTML-структуру доски, поэтому нам уже заранее известно, как в нее "вставлять" фигуры: просто вписывая в <div> клетки текст с фигурой. Примерно так:

async onload() {
	this.registerMarkdownCodeBlockProcessor('chess', (source, el, ctx) => {
		// Parse code
		const data = new BoardData(source)

		// Render chess board
		el.innerHTML = boardHtml

		// Fill chess board with pieces
		for (const [addr, piece] of Object.entries(data.addresses)) {
			const cell = el.querySelector('[id=' + addr + ']')
			if (cell) {
				cell.innerText = piece
			}
		}
	})
}

Смотрите, мы заранее заложились на мифическую BoardData, которой еще нет, но мы знаем, что у нее должны быть:

  • Конструктор, принимающий в себя source-HTML

  • Поле addresses — словарик вида {адрес; ACSII-фигура}

Осталось лишь реализовать BoardData, и плагин должен заработать.

Парсим и отображаем FEN

Требования к парсящему классу у нас уже сформированы в предыдущем разделе: наша конечная цель — получить поле-словарь addresses с адресами фигур. Делаем:

class BoardData {
    // map of {address piece} like {'a4': '♞'}
    addresses = {}

    constructor(source) {
        const lines = source.split('\n')
        const line = lines?.[0]
        this.#parseFen(line)
    }

    #parseFen(fen) {
        const rowLines = fen.split('/')
    
        let row = 8
        let col = 1
    
        this.addresses = {}
    
        for (const rowLine of rowLines) {
            col = 1
            for (const ch of rowLine) {
                if (isLetter(ch)) {
                    let piece = ''
    
                    if (ch == 'p') piece = '♟'
                    else if (ch == 'r') piece = '♜'
                    else if (ch == 'n') piece = '♞'
                    else if (ch == 'b') piece = '♝'
                    else if (ch == 'q') piece = '♛'
                    else if (ch == 'k') piece = '♚'
                    else if (ch == 'P') piece = '♙'
                    else if (ch == 'R') piece = '♖'
                    else if (ch == 'N') piece = '♘'
                    else if (ch == 'B') piece = '♗'
                    else if (ch == 'Q') piece = '♕'
                    else if (ch == 'K') piece = '♔'
    
                    this.addresses[getAddress(row, col)] = piece
    
                    col += 1
                } else {
                    col += parseInt(ch, 10)
                }
            }
            row -= 1
        }
    }
}

Этот класс использует щепотку вспомогательных функций:

function isLetter(c) {
    return c.toLowerCase() != c.toUpperCase()
}

function rowToString(r) {
    return r.toString()
}

function colToString(c) {
    return String.fromCharCode('a'.charCodeAt() + c - 1)
}

function getAddress(r, c) {
    return colToString(c) + rowToString(r)
}

Теперь плагин должен рисовать шахматные позиции в соответствии с FEN-описанием. Достаем нашу старую заготовку:

# Two rooks mate

```chess
8/8/4k3/R7/7R/8/8/6K1
```

```chess
8/4k3/7R/R7/8/8/8/6K1
```

Заметка приобретает вид:

Ура — вот мы и добились работоспособного отображения доски и фигур, я нас поздравляю.

Подсветка клеток

У нас есть базовое отображение доски, но отсутствует одна существенная для шахматных зарисовок вещь — возможность помечать клетки цветами, чтобы можно было яснее выражать мысль и развитие событий на доске. Вот как, например, это реализовано в chess.com:

Я хотел бы иметь в арсенале зеленый, красный и синий цвета. Так же есть требование, чтобы цвета ложились на клетку с некоторой прозрачностью, чтобы все еще можно было отличить белую и черную клетки, даже если они подсвечены. Так же подошел бы эффект "виньетка".

Раскрашивание клетки будем реализовывать через программное добавление подсвеченной клетке одного из трех специальных классов: .green-hihglight, .red-hihglight, .blue-hihglight:

.red-hihglight {
    box-shadow: 0 0 calc(var(--cell-size)*2) rgba(255, 0, 0, 0.75) inset;
}

.blue-hihglight {
    box-shadow: 0 0 calc(var(--cell-size)*2) rgba(0, 0, 255, 0.4) inset;
}

.green-hihglight {
    box-shadow: 0 0 calc(var(--cell-size)*2) rgba(0, 255, 0, 0.5) inset;
}

Как видите, в классе присутствует некий box-shadow со странными вычислениями. Это результат моих исследований наложения эффекта "виньетка" на клетку разными цветами. По итогу получилась не то чтобы виньетка, но результат меня устроил — скоро увидите.

Для начала нам стоит понять, как описывать информацию о выделенных клетках в Obsidian-заметке. Я пришел к той же схеме, какой пользовались все известные мне шахматные плагины:

```chess
fen: rnbqkbnr/pp2pppp/2p5/3p4/3PP3/8/PPP2PPP/RNBQKBNR
green: d7 d5
red: e4
```

А это значит что? Что у нас усложнится парсер класса BoardData. Так же у него прибавится полей:

class BoardData {
	...
	
    // arrays of [address] like [e2, e4]
    reds = []
    greens = []
    blues = []

    constructor(source) {
        const lines = source.split('\n').map(line => line.trim())

        for (const line of lines) {
            if (line.startsWith('fen:')) {
                const fen = removePrefix(line, 'fen:').trim()
                this.#parseFen(fen)
            } else if (line.startsWith('red:')) {
                const redLine = removePrefix(line, 'red:').trim()
                this.reds = redLine.split(' ')
            } else if (line.startsWith('green:')) {
                const greenLine = removePrefix(line, 'green:').trim()
                this.greens = greenLine.split(' ')
            } else if (line.startsWith('blue:')) {
                const blueLine = removePrefix(line, 'blue:').trim()
                this.blues = blueLine.split(' ')
            } else {
                if (line.contains(':')) {
                    continue
                }
                this.#parseFen(line)
            }
        }
    }
	...
}

А в плагин допишем использование новых распаршенных данных:

class ChessLightweightPlugin extends obsidian.Plugin {
    async onload() {
        this.registerMarkdownCodeBlockProcessor('chess', (source, el, ctx) => {
            // Parse code
            const data = new BoardData(source)

			...
			
            for (const addr of data.reds) {
                el.querySelector('[id=' + addr + ']').classList.add('red-hihglight')
            }

            for (const addr of data.greens) {
                el.querySelector('[id=' + addr + ']').classList.add('green-hihglight')
            }

            for (const addr of data.blues) {
                el.querySelector('[id=' + addr + ']').classList.add('blue-hihglight')
            }
        })
    }
}

Все, у нас все готово. Теперь доски в заметках могут показывать больше информации о происходящем:

Не знаю как вам, а я доволен результатом. Для полного счастья мне не хватает только одного...

Госпереворот доски

Предыдущая картинка описывает дебют Каро-Канн за черных. Его намного удобнее наблюдать со стороны черных. Хочу:

```chess
...
flipBoard: true
```

Как парсить это поле, я уж не стану показывать, это тривиальная задача. Задача чуть сложнее — как реально перевернуть доску? Применить transform? Программно переписать адреса клеток? Тут, как водится, в дело вступает щепотка говнокода, которая еще никому не помешала:

const whitesBoardHtml =
`<div class="board">
    <div class="row" id="row8">
        <div class="square white" id="a8"></div>
        <div class="square black" id="b8"></div>
        <div class="square white" id="c8"></div>
        <div class="square black" id="d8"></div>
        <div class="square white" id="e8"></div>
        <div class="square black" id="f8"></div>
        <div class="square white" id="g8"></div>
        <div class="square black" id="h8"></div>
    </div>
...
    <div class="row" id="row1">
        <div class="square black" id="a1"></div>
        <div class="square white" id="b1"></div>
        <div class="square black" id="c1"></div>
        <div class="square white" id="d1"></div>
        <div class="square black" id="e1"></div>
        <div class="square white" id="f1"></div>
        <div class="square black" id="g1"></div>
        <div class="square white" id="h1"></div>
    </div>
</div>`

const blacksBoardHtml =
`<div class="board">
    <div class="row" id="row1">
        <div class="square white" id="h1"></div>
        <div class="square black" id="g1"></div>
        <div class="square white" id="f1"></div>
        <div class="square black" id="e1"></div>
        <div class="square white" id="d1"></div>
        <div class="square black" id="c1"></div>
        <div class="square white" id="b1"></div>
        <div class="square black" id="a1"></div>
    </div>
...
    <div class="row" id="row8">
        <div class="square black" id="h8"></div>
        <div class="square white" id="g8"></div>
        <div class="square black" id="f8"></div>
        <div class="square white" id="e8"></div>
        <div class="square black" id="d8"></div>
        <div class="square white" id="c8"></div>
        <div class="square black" id="b8"></div>
        <div class="square white" id="a8"></div>
    </div>
</div>`

Не, ну а что? Всего лишних восемьдесят строк позора, и вот вы уже пишете в плагине:

async onload() {
	this.registerMarkdownCodeBlockProcessor('chess', (source, el, ctx) => {
		// Parse code
        const data = new BoardData(source)
        
		...

		// Render chess board
		el.innerHTML = data.flipBoard ? blacksBoardHtml : whitesBoardHtml

		...
	})
}

И вот вы уже комфортно сидите на месте черных:

Все?

Все :)

Могли бы мы сделать плагин еще лучше? Безусловно! В данный момент на мне висит канбан-доска с вот таким списком TODO для этого плагина:

Когда-нибудь у меня доберутся руки закрыть и все эти задачи, но на сегодняшний момент я могу пользоваться плагином, как есть, а вы и так почерпнули из этой статьи ту единственную мысль, что я хотел донести:

Просто подложите styles.css в папку плагина!

Да, фактически, все остальное в статье это не про плагиностроение, а про верстку, бизнес-логику и щепотку вау-эффекта, когда ваша заметка из текста превращается в мини-веб-страничку. Так что считайте, что это моя небольшая афера.


На этом наша эпопея с написанием плагинов для Obsidian заканчивается, я желаю вам успехов в написании собственных плагинов для Obsidian, ведь теперь вы знаете, что это совершенно несложно, но очень весело, а главное — может быть полезно конкретно в ваших повседневных операциях с заметками.

Как и обещал, прикладываю ссылку на GitHub, если кто-то захочет поисследовать или использовать написанные нами здесь плагины.