From e720ec03c411a9b1a78a4aa11d6e8ae1ef777cd5 Mon Sep 17 00:00:00 2001 From: "R. Campos" Date: Thu, 1 Oct 2026 17:05:19 -0300 Subject: [PATCH 1/2] docs: update changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f35e7a6..21b015b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - URL rendering in a note. +``` +# Docker images +- ghcr.io/thermcampos/tasknote/client:app-v2026.10.01.20 +``` + --- ## 2026-09-29 @@ -19,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Email sending migrated from Mailgun (named templates) to Resend (HTML bodies from local templates). The `MAILGUN_APIKEY` environment variable was replaced by `RESEND_APIKEY`. +``` +# Docker images +- ghcr.io/thermcampos/tasknote/server:api-v2026.09.29.15 +``` + --- ## 2026-09-28 From 3820a44f7538bdbe32e544bedbdb0a01b31481a9 Mon Sep 17 00:00:00 2001 From: "R. Campos" Date: Thu, 1 Oct 2026 17:19:35 -0300 Subject: [PATCH 2/2] fix: checkboxes rendering on notes --- CHANGELOG.md | 7 ++ .../components/MarkdownEditor.test.tsx | 33 ++++++- .../src/components/MarkdownEditor/index.tsx | 2 + .../MarkdownEditor/taskListItemView.ts | 88 +++++++++++++++++++ client/src/styles/custom.scss | 19 +++- 5 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 client/src/components/MarkdownEditor/taskListItemView.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b015b..5dee1c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## 2026-10-01 + +### Fixed +- Checkbox not rendering when creating/editing notes [PR 11](https://github.com/thermcampos/tasknote/pull/11) + +--- + ## 2026-09-30 ### Fixed diff --git a/client/src/__test__/components/MarkdownEditor.test.tsx b/client/src/__test__/components/MarkdownEditor.test.tsx index 8afda29..7399bd6 100644 --- a/client/src/__test__/components/MarkdownEditor.test.tsx +++ b/client/src/__test__/components/MarkdownEditor.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { beforeAll, describe, expect, it, vi } from 'vitest'; import MarkdownEditor from '../../components/MarkdownEditor'; @@ -50,6 +50,37 @@ describe('MarkdownEditor', () => { }); }); + it('should render task list items as checkboxes and toggle them', async () => { + const onChange = vi.fn(); + const { container } = render( + + ); + + await waitFor(() => { + expect(container.querySelectorAll('li input[type="checkbox"]').length).toBe(2); + }); + + const checkboxes = container.querySelectorAll('li input[type="checkbox"]'); + expect(checkboxes[0].checked).toBe(false); + expect(checkboxes[1].checked).toBe(true); + expect(checkboxes[0].closest('li')?.getAttribute('data-item-type')).toBe('task'); + + const plainItem = Array.from(container.querySelectorAll('li')).find( + li => li.textContent === 'plain item' + ); + expect(plainItem?.querySelector('input[type="checkbox"]')).toBeNull(); + + fireEvent.click(checkboxes[0]); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(expect.stringContaining('[x] open task')); + }); + }); + it('should mark the editor as empty when there is no content', async () => { const { getByTestId } = render( diff --git a/client/src/components/MarkdownEditor/index.tsx b/client/src/components/MarkdownEditor/index.tsx index 76c4e53..fa64cab 100644 --- a/client/src/components/MarkdownEditor/index.tsx +++ b/client/src/components/MarkdownEditor/index.tsx @@ -6,6 +6,7 @@ import { gfm } from '@milkdown/kit/preset/gfm'; import { history } from '@milkdown/kit/plugin/history'; import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'; import { insertLinkInputRule } from './linkInputRule'; +import { taskListItemView } from './taskListItemView'; import '@milkdown/kit/prose/view/style/prosemirror.css'; import '@milkdown/kit/prose/tables/style/tables.css'; @@ -38,6 +39,7 @@ const MarkdownEditorInner = forwardRef ({ diff --git a/client/src/components/MarkdownEditor/taskListItemView.ts b/client/src/components/MarkdownEditor/taskListItemView.ts new file mode 100644 index 0000000..3ee4959 --- /dev/null +++ b/client/src/components/MarkdownEditor/taskListItemView.ts @@ -0,0 +1,88 @@ +import { listItemSchema } from '@milkdown/kit/preset/commonmark'; +import type { Node } from '@milkdown/kit/prose/model'; +import type { EditorView, NodeView, ViewMutationRecord } from '@milkdown/kit/prose/view'; +import { $view } from '@milkdown/kit/utils'; + +class TaskListItemView implements NodeView { + dom: HTMLLIElement; + contentDOM: HTMLDivElement; + private checkbox: HTMLInputElement | null = null; + + constructor( + private node: Node, + private readonly view: EditorView, + private readonly getPos: () => number | undefined + ) { + this.dom = document.createElement('li'); + this.contentDOM = document.createElement('div'); + this.dom.appendChild(this.contentDOM); + this.render(); + } + + update(node: Node): boolean { + if (node.type !== this.node.type) return false; + this.node = node; + this.render(); + return true; + } + + ignoreMutation(mutation: ViewMutationRecord): boolean { + return this.checkbox !== null && mutation.target === this.checkbox; + } + + destroy(): void { + this.checkbox?.removeEventListener('change', this.handleToggle); + } + + private render(): void { + const { checked, label, listType, spread } = this.node.attrs; + this.dom.dataset.label = label; + this.dom.dataset.listType = listType; + this.dom.dataset.spread = String(spread); + + if (checked === null || checked === undefined) { + delete this.dom.dataset.itemType; + delete this.dom.dataset.checked; + if (this.checkbox) { + this.checkbox.removeEventListener('change', this.handleToggle); + this.checkbox.remove(); + this.checkbox = null; + } + return; + } + + this.dom.dataset.itemType = 'task'; + this.dom.dataset.checked = String(checked); + + if (!this.checkbox) { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'task-list-item-checkbox'; + checkbox.setAttribute('contenteditable', 'false'); + checkbox.setAttribute('aria-label', 'Toggle task'); + checkbox.addEventListener('mousedown', event => event.preventDefault()); + checkbox.addEventListener('change', this.handleToggle); + this.dom.insertBefore(checkbox, this.contentDOM); + this.checkbox = checkbox; + } + this.checkbox.checked = Boolean(checked); + } + + private handleToggle = (event: Event): void => { + const pos = this.getPos(); + if (pos === undefined) return; + const checked = (event.target as HTMLInputElement).checked; + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, checked }) + ); + }; +} + +/** + * Milkdown node view that renders GFM task list items (`- [ ]`/`- [x]`) with a + * clickable checkbox instead of a plain bullet. Toggling the checkbox updates + * the `checked` attribute so the serialized markdown stays in sync. + */ +export const taskListItemView = $view(listItemSchema.node, () => { + return (node, view, getPos) => new TaskListItemView(node, view, getPos); +}); diff --git a/client/src/styles/custom.scss b/client/src/styles/custom.scss index cddf9f0..447221a 100644 --- a/client/src/styles/custom.scss +++ b/client/src/styles/custom.scss @@ -317,9 +317,26 @@ a:hover, .btn-link:hover { padding: 4px 8px; } -.milkdown-editor .ProseMirror li:has(> input[type='checkbox']) { +.milkdown-editor .ProseMirror li[data-item-type='task'] { list-style: none; margin-left: -1.4rem; + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.milkdown-editor .ProseMirror li[data-item-type='task'] > input[type='checkbox'] { + cursor: pointer; + flex-shrink: 0; +} + +.milkdown-editor .ProseMirror li[data-item-type='task'] > div { + flex: 1; +} + +.milkdown-editor .ProseMirror li[data-item-type='task'][data-checked='true'] > div { + text-decoration: line-through; + color: var(--bs-secondary-color); } .milkdown-editor .ProseMirror a {