Fix issue with positioning and size of todo list checkboxes in Markdown blocks

Use two different ways of positioning the checkbox depending on if the font is monospaced or not
This commit is contained in:
Jonatan Heyman 2025-04-07 11:26:15 +02:00
parent 50f3cae372
commit 85f59661e9
5 changed files with 77 additions and 18 deletions

View File

@ -2,6 +2,10 @@
Here are the most notable changes in each release. For a more detailed list of changes, see the [Github Releases page](https://github.com/heyman/heynote/releases).
## 2.1.4 (not released yet)
- Fix issue with positioning and size of todo list checkboxes in Markdown blocks when using a non-default font size, or a non-monospaced font.
## 2.1.3
- Fix escaping issue in buffer selector (properly this time, hopefully)

View File

@ -8,3 +8,4 @@ export const ADD_NEW_BLOCK = "heynote-add-new-block"
export const DELETE_BLOCK = "heynote-delete-block"
export const CURSOR_CHANGE = "heynote-cursor-change"
export const APPEND_BLOCK = "heynote-append-block"
export const SET_FONT = "heynote-set-font"

View File

@ -12,7 +12,7 @@ import { getFontTheme } from "./theme/font-theme.js";
import { customSetup } from "./setup.js"
import { heynoteLang } from "./lang-heynote/heynote.js"
import { noteBlockExtension, blockLineNumbers, blockState, getActiveNoteBlock, triggerCursorChange } from "./block/block.js"
import { heynoteEvent, SET_CONTENT, DELETE_BLOCK, APPEND_BLOCK } from "./annotation.js";
import { heynoteEvent, SET_CONTENT, DELETE_BLOCK, APPEND_BLOCK, SET_FONT } from "./annotation.js";
import { changeCurrentBlockLanguage, triggerCurrenciesLoaded, getBlockDelimiter, deleteBlock, selectAll } from "./block/commands.js"
import { formatBlockContent } from "./block/format-code.js"
import { heynoteKeymap } from "./keymap.js"
@ -135,6 +135,11 @@ export class HeynoteEditor {
if (focus) {
this.view.focus()
}
// trigger setFont once the fonts has loaded
document.fonts.ready.then(() => {
this.setFont(fontFamily, fontSize)
})
}
async save() {
@ -258,6 +263,7 @@ export class HeynoteEditor {
setFont(fontFamily, fontSize) {
this.view.dispatch({
effects: this.fontTheme.reconfigure(getFontTheme(fontFamily, fontSize)),
annotations: [heynoteEvent.of(SET_FONT), Transaction.addToHistory.of(false)],
})
}

View File

@ -1,11 +1,39 @@
import { EditorView } from "@codemirror/view"
import { Facet } from "@codemirror/state"
/**
* Check if the given font family is monospace by drawing test characters on a canvas
*/
function isMonospace(fontFamily) {
const testCharacters = ['i', 'W', 'm', ' ']
const testSize = '72px'
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
context.font = `${testSize} ${fontFamily}`
const widths = testCharacters.map(char => context.measureText(char).width)
return widths.every(width => width === widths[0])
}
export const isMonospaceFont = Facet.define({
combine: values => values.length ? values[0] : true,
})
export function getFontTheme(fontFamily, fontSize) {
fontSize = fontSize || window.heynote.defaultFontSize
return EditorView.theme({
const computedFontFamily = fontFamily || window.heynote.defaultFontFamily
return [
EditorView.theme({
'.cm-scroller': {
fontFamily: fontFamily || window.heynote.defaultFontFamily,
fontFamily: computedFontFamily,
fontSize: (fontSize) + "px",
},
})
}),
// in order to avoid a short flicker when the program is loaded with the default font (Hack),
// we hardcode Hack to be monospace
isMonospaceFont.of(computedFontFamily === "Hack" ? true : isMonospace(computedFontFamily)),
]
}

View File

@ -3,25 +3,44 @@ import { syntaxTree, ensureSyntaxTree } from "@codemirror/language"
import { WidgetType } from "@codemirror/view"
import { ViewUpdate, ViewPlugin, DecorationSet } from "@codemirror/view"
import { isMonospaceFont } from "./theme/font-theme"
import { SET_FONT } from "./annotation"
class CheckboxWidget extends WidgetType {
constructor(readonly checked: boolean) { super() }
constructor(readonly checked: boolean, readonly monospace: boolean) { super() }
eq(other: CheckboxWidget) { return other.checked == this.checked }
eq(other: CheckboxWidget) { return other.checked == this.checked && other.monospace == this.monospace }
toDOM() {
let wrap = document.createElement("span")
wrap.setAttribute("aria-hidden", "true")
wrap.className = "cm-taskmarker-toggle"
wrap.style.position = "relative"
// Three spaces since it's the same width as [ ] and [x]
wrap.appendChild(document.createTextNode(" "))
let box = wrap.appendChild(document.createElement("input"))
let box = document.createElement("input")
box.type = "checkbox"
box.checked = this.checked
box.style.margin = "0"
box.style.padding = "0"
if (this.monospace) {
// if the font is monospaced, we'll set the content of the wrapper to " " and the
// position of the checkbox to absolute, since three spaces will be the same width
// as "[ ]" and "[x]" so that characters on different lines will line up
wrap.appendChild(document.createTextNode(" "))
wrap.style.position = "relative"
box.style.position = "absolute"
box.style.top = "-3px"
box.style.left = "0"
box.style.top = "0"
box.style.left = "0.25em"
box.style.width = "1.1em"
box.style.height = "1.1em"
} else {
// if the font isn't monospaced, we'll let the checkbox take up as much space as needed
box.style.position = "relative"
box.style.top = "0.1em"
box.style.marginRight = "0.5em"
}
wrap.appendChild(box)
return wrap
}
@ -52,7 +71,7 @@ function checkboxes(view: EditorView) {
if (view.state.doc.sliceString(nodeRef.to, nodeRef.to+1) === " ") {
let isChecked = view.state.doc.sliceString(nodeRef.from, nodeRef.to).toLowerCase() === "[x]"
let deco = Decoration.replace({
widget: new CheckboxWidget(isChecked),
widget: new CheckboxWidget(isChecked, view.state.facet(isMonospaceFont)),
inclusive: false,
})
widgets.push(deco.range(nodeRef.from, nodeRef.to))
@ -92,9 +111,10 @@ export const todoCheckboxPlugin = [
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged)
if (update.docChanged || update.viewportChanged || update.transactions.some(tr => tr.annotations.some(a => a.value === SET_FONT))) {
this.decorations = checkboxes(update.view)
}
}
}, {
decorations: v => v.decorations,