Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,35 @@ 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
- URL rendering in a note.

```
# Docker images
- ghcr.io/thermcampos/tasknote/client:app-v2026.10.01.20
```

---

## 2026-09-29

### 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
Expand Down
33 changes: 32 additions & 1 deletion client/src/__test__/components/MarkdownEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(
<MarkdownEditor
defaultValue={'- [ ] open task\n- [x] done task\n- plain item'}
placeholder="Write something..."
onChange={onChange}
/>
);

await waitFor(() => {
expect(container.querySelectorAll('li input[type="checkbox"]').length).toBe(2);
});

const checkboxes = container.querySelectorAll<HTMLInputElement>('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(
<MarkdownEditor defaultValue="" placeholder="Write something..." onChange={vi.fn()} />
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/MarkdownEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -38,6 +39,7 @@ const MarkdownEditorInner = forwardRef<MarkdownEditorHandle, MarkdownEditorProps
.use(listener)
.use(history)
.use(insertLinkInputRule)
.use(taskListItemView)
);

useImperativeHandle(ref, () => ({
Expand Down
88 changes: 88 additions & 0 deletions client/src/components/MarkdownEditor/taskListItemView.ts
Original file line number Diff line number Diff line change
@@ -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);
});
19 changes: 18 additions & 1 deletion client/src/styles/custom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading