diff --git a/docs/changelog.md b/docs/changelog.md index 9dc65cd..c8c65f3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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) diff --git a/src/editor/annotation.js b/src/editor/annotation.js index 2f6dc46..09342c2 100644 --- a/src/editor/annotation.js +++ b/src/editor/annotation.js @@ -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" diff --git a/src/editor/editor.js b/src/editor/editor.js index 749328a..1ee2ab9 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -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)], }) } diff --git a/src/editor/theme/font-theme.js b/src/editor/theme/font-theme.js index 0dc7879..2728fdc 100644 --- a/src/editor/theme/font-theme.js +++ b/src/editor/theme/font-theme.js @@ -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({ - '.cm-scroller': { - fontFamily: fontFamily || window.heynote.defaultFontFamily, - fontSize: (fontSize) + "px", - }, - }) + const computedFontFamily = fontFamily || window.heynote.defaultFontFamily + return [ + EditorView.theme({ + '.cm-scroller': { + 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)), + ] } diff --git a/src/editor/todo-checkbox.ts b/src/editor/todo-checkbox.ts index 0fd69f3..60d76a6 100644 --- a/src/editor/todo-checkbox.ts +++ b/src/editor/todo-checkbox.ts @@ -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.position = "absolute" - box.style.top = "-3px" - box.style.left = "0" + 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 = "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,8 +111,9 @@ 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,