Better todos
This commit is contained in:
parent
6ee2a00f07
commit
8843349413
3
bun.lock
3
bun.lock
|
|
@ -109,6 +109,7 @@
|
|||
"@lezer/lr": "^1.4.2",
|
||||
"hono": "catalog:",
|
||||
"luxon": "^3.6.1",
|
||||
"zzfx": "^1.3.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
|
|
@ -723,6 +724,8 @@
|
|||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
|
||||
"zzfx": ["zzfx@1.3.0", "", {}, "sha512-59fM+Oo/0NBmjK0G7Ryo1Zt/2MiIKAhtCiFDbuyopihJgZxFB+Ow9Gdgu77KAdiNSS1E7mMqduXV1hnNkBb/eg=="],
|
||||
|
||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
|
|
|||
3
packages/todo/global.d.ts
vendored
Normal file
3
packages/todo/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
declare module "zzfx" {
|
||||
export function zzfx(...args: any[]): void
|
||||
}
|
||||
|
|
@ -14,7 +14,8 @@
|
|||
"@lezer/highlight": "^1.2.1",
|
||||
"@lezer/lr": "^1.4.2",
|
||||
"hono": "catalog:",
|
||||
"luxon": "^3.6.1"
|
||||
"luxon": "^3.6.1",
|
||||
"zzfx": "^1.3.0"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { checkboxRegex } from "@/todo"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
|
||||
export const autoTodoOnNewline = EditorView.updateListener.of((update) => {
|
||||
|
|
@ -7,31 +8,48 @@ export const autoTodoOnNewline = EditorView.updateListener.of((update) => {
|
|||
if (!tr.isUserEvent("input")) return
|
||||
|
||||
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
if (inserted.toString() !== "\n") return
|
||||
|
||||
const prefix = "- [ ] "
|
||||
// If previous line was an empty todo, remove the empty line instead of continuing
|
||||
const insertPos = fromB + inserted.toString().length
|
||||
const prevLine = update.state.doc.lineAt(insertPos - 1)
|
||||
|
||||
// if the previous line is empty or just whitespace, just insert the newline
|
||||
if (prevLine.text.trim() === "") {
|
||||
return
|
||||
} else if (prevLine.text.trim() === prefix.trim()) {
|
||||
const start = prevLine.from
|
||||
const end = insertPos
|
||||
update.view.dispatch({
|
||||
changes: { from: start, to: end, insert: "" },
|
||||
selection: { anchor: start },
|
||||
userEvent: "delete",
|
||||
})
|
||||
} else {
|
||||
update.view.dispatch({
|
||||
changes: { from: insertPos, to: insertPos, insert: prefix },
|
||||
selection: { anchor: insertPos + prefix.length },
|
||||
userEvent: "todo.newline",
|
||||
})
|
||||
}
|
||||
handleNewlineChange({ update, fromA, toA, fromB, toB, inserted })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type HandleNewlineChangeOpts = {
|
||||
update: any
|
||||
fromA: number
|
||||
toA: number
|
||||
fromB: number
|
||||
toB: number
|
||||
inserted: any
|
||||
}
|
||||
|
||||
const handleNewlineChange = (opts: HandleNewlineChangeOpts) => {
|
||||
if (!opts.inserted.toString().match(/\s*\n/)) return
|
||||
|
||||
let input = opts.inserted.toString().replace(/\n/g, "\\n").replace(/\s/g, "\\s")
|
||||
const insertPos = opts.fromB + opts.inserted.toString().length
|
||||
const prevLine = opts.update.state.doc.lineAt(insertPos - opts.inserted.toString().length)
|
||||
|
||||
if (prevLine.text.trim() === "") {
|
||||
return
|
||||
} else if (endsWith(prevLine.text, checkboxRegex)) {
|
||||
const start = prevLine.from
|
||||
const end = insertPos
|
||||
opts.update.view.dispatch({
|
||||
changes: { from: start, to: end, insert: "" },
|
||||
selection: { anchor: start },
|
||||
userEvent: "delete",
|
||||
})
|
||||
} else {
|
||||
const prefix = `- [ ] `
|
||||
opts.update.view.dispatch({
|
||||
changes: { from: insertPos, to: insertPos, insert: prefix },
|
||||
selection: { anchor: insertPos + prefix.length },
|
||||
userEvent: "todo.newline",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const endsWith = (str: string, regex: RegExp) => {
|
||||
const match = str.match(regex)
|
||||
return match && match.index === str.length - match[0].length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,39 @@
|
|||
/* Todo styling */
|
||||
.todo-completed {
|
||||
display: inline-block;
|
||||
animation: todoComplete 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards !important;
|
||||
}
|
||||
|
||||
.todo-completed * {
|
||||
text-decoration: line-through;
|
||||
color: #D1D5DB !important;
|
||||
color: black !important;
|
||||
opacity: 0.5;
|
||||
|
||||
}
|
||||
|
||||
@keyframes todoComplete {
|
||||
0% {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
15% {
|
||||
transform: scale(1.15) rotate(2deg);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.1) rotate(-1deg);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05) rotate(1deg);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.95) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.todo-tag {
|
||||
|
|
@ -60,7 +92,7 @@
|
|||
opacity : 0.3;
|
||||
}
|
||||
|
||||
.timer-running .cm-line.neat {
|
||||
.timer-running .cm-line.cm-timer {
|
||||
opacity : 1;
|
||||
}
|
||||
|
||||
|
|
@ -80,4 +112,22 @@
|
|||
color: black;
|
||||
padding: 2px 5px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* blink finished output */
|
||||
.countdown-timer.finished {
|
||||
animation: blink 1s infinite;
|
||||
color: red;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,15 +19,17 @@ test("parsing valid todos", () => {
|
|||
})
|
||||
|
||||
test("todo nodes", () => {
|
||||
const todo = Todo.parse("- [ ] some #words and @2/2/25")
|
||||
const todo = Todo.parse(" - [ ] some #words and @2/2/25")
|
||||
expect(todo!.nodes).toEqual([
|
||||
{ type: "text", content: "some", start: 6 },
|
||||
{ type: "whitespace", content: " ", start: 10 },
|
||||
{ type: "tag", content: "#words", start: 11 },
|
||||
{ type: "whitespace", content: " ", start: 17 },
|
||||
{ type: "text", content: "and", start: 18 },
|
||||
{ type: "whitespace", content: " ", start: 21 },
|
||||
{ type: "date", content: "@2/2/25", parsed: DateTime.fromISO("2025-02-02"), start: 22 },
|
||||
{ type: "indent", content: " " },
|
||||
{ type: "checkbox", content: "- [ ] ", checked: false },
|
||||
{ type: "text", content: "some" },
|
||||
{ type: "whitespace", content: " " },
|
||||
{ type: "tag", content: "#words" },
|
||||
{ type: "whitespace", content: " " },
|
||||
{ type: "text", content: "and" },
|
||||
{ type: "whitespace", content: " " },
|
||||
{ type: "date", content: "@2/2/25", parsed: DateTime.fromISO("2025-02-02") },
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export type TodoJSON = {
|
|||
children: TodoJSON[]
|
||||
}
|
||||
|
||||
export const checkboxRegex = /^\s*-\s*\[\s*(\w?)\s*\]\s*/
|
||||
|
||||
export class Todo {
|
||||
done: boolean
|
||||
indent = ""
|
||||
|
|
@ -33,12 +35,24 @@ export class Todo {
|
|||
return string
|
||||
}
|
||||
|
||||
toJSON(): TodoJSON {
|
||||
return {
|
||||
done: this.done,
|
||||
indent: this.indent,
|
||||
text: this.text,
|
||||
children: this.children.map((child) => child.toJSON()),
|
||||
}
|
||||
}
|
||||
|
||||
addChild(todo: Todo) {
|
||||
this.children.push(todo)
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this.nodes.map((node) => node.content).join("")
|
||||
return this.nodes
|
||||
.filter((node) => node.type !== "indent" && node.type !== "checkbox")
|
||||
.map((node) => node.content)
|
||||
.join("")
|
||||
}
|
||||
|
||||
get timeEstimate() {
|
||||
|
|
@ -55,7 +69,9 @@ export class Todo {
|
|||
}
|
||||
}
|
||||
|
||||
type TodoNode = { content: string; start: number } & (
|
||||
type TodoNode = { content: string } & (
|
||||
| { type: "indent" }
|
||||
| { type: "checkbox"; checked: boolean }
|
||||
| { type: "whitespace" }
|
||||
| { type: "text" }
|
||||
| { type: "date"; parsed: DateTime }
|
||||
|
|
@ -73,17 +89,22 @@ ${" ".repeat(offset)}^ error occurred here`
|
|||
}
|
||||
|
||||
const parseTodo = (line: string): Todo | undefined => {
|
||||
const todoNodes: TodoNode[] = []
|
||||
|
||||
// Grab the indent at the start of the line
|
||||
const indentMatch = line.match(/^\s+/)
|
||||
const indent = indentMatch?.[0] ?? ""
|
||||
todoNodes.push({ type: "indent", content: indent })
|
||||
let offset = indent.length
|
||||
|
||||
// Check for status
|
||||
const match = line.match(/\s*-\s*\[\s*(\w?)\s*\]\s+/i)
|
||||
// Check for done status
|
||||
const match = line.slice(offset).match(checkboxRegex)
|
||||
if (!match) return // Invalid format, no status found
|
||||
const done = Boolean(match[1])
|
||||
offset = match[0].length
|
||||
todoNodes.push({ type: "checkbox", content: match[0], checked: done })
|
||||
offset += match[0].length
|
||||
|
||||
const todoNodes: TodoNode[] = []
|
||||
// Parse everything else in the line
|
||||
let previousOffset = -1
|
||||
while (offset < line.length) {
|
||||
ensure(offset !== previousOffset, parseError(line, offset, `Infinite loop detected.`))
|
||||
|
|
@ -93,7 +114,7 @@ const parseTodo = (line: string): Todo | undefined => {
|
|||
const whitespaceMatch = line.slice(offset).match(/^\s+/)
|
||||
if (whitespaceMatch) {
|
||||
const content = whitespaceMatch[0]
|
||||
todoNodes.push({ type: "whitespace", content, start: offset })
|
||||
todoNodes.push({ type: "whitespace", content })
|
||||
offset += content.length
|
||||
continue
|
||||
}
|
||||
|
|
@ -142,7 +163,7 @@ const parseTimeEstimate = (line: string, start: number) => {
|
|||
return parseText(line, start)
|
||||
}
|
||||
|
||||
const node: TodoNode = { type: "time-estimate", content: timeMatch![0], seconds, start }
|
||||
const node: TodoNode = { type: "time-estimate", content: timeMatch![0], seconds }
|
||||
return node
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +174,7 @@ const parseTag = (line: string, start: number) => {
|
|||
return parseText(line, start)
|
||||
}
|
||||
|
||||
const node: TodoNode = { type: "tag", content, start }
|
||||
const node: TodoNode = { type: "tag", content }
|
||||
|
||||
return node
|
||||
}
|
||||
|
|
@ -174,7 +195,7 @@ const parseDate = (line: string, start: number) => {
|
|||
const date = DateTime.fromMillis(Date.parse(content.slice(1)))
|
||||
|
||||
if (date.isValid) {
|
||||
const node: TodoNode = { type: "date", content, parsed: date, start }
|
||||
const node: TodoNode = { type: "date", content, parsed: date }
|
||||
return node
|
||||
} else {
|
||||
return parseText(line, start)
|
||||
|
|
@ -186,7 +207,7 @@ const parseText = (line: string, start: number) => {
|
|||
const content = textMatch?.[0]
|
||||
if (!content) return
|
||||
|
||||
const node: TodoNode = { type: "text", content, start }
|
||||
const node: TodoNode = { type: "text", content }
|
||||
|
||||
return node
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { EditorView } from "@codemirror/view"
|
||||
import { Todo } from "@/todo"
|
||||
import { triggerTodoCompletionEffect } from "./todoCompletion"
|
||||
|
||||
export const todoClickHandler = EditorView.domEventHandlers({
|
||||
click(event, view) {
|
||||
|
|
@ -28,6 +29,7 @@ export const todoClickHandler = EditorView.domEventHandlers({
|
|||
if (clickOffset >= 0 && clickOffset <= fullMatch.length) {
|
||||
// Toggle the checkbox state
|
||||
const newState = todo.done ? " " : "x"
|
||||
const isCompleting = !todo.done // Will be completing if currently not done
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
|
|
@ -37,6 +39,11 @@ export const todoClickHandler = EditorView.domEventHandlers({
|
|||
},
|
||||
})
|
||||
|
||||
// Add animation effect when completing (not uncompleting)
|
||||
if (isCompleting) {
|
||||
triggerTodoCompletionEffect(view, line.from)
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
13
packages/todo/src/todoCompletion.ts
Normal file
13
packages/todo/src/todoCompletion.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { RangeSetBuilder } from "@codemirror/state"
|
||||
import { Decoration, EditorView } from "@codemirror/view"
|
||||
import { zzfx } from "zzfx"
|
||||
|
||||
export function triggerTodoCompletionEffect(view: EditorView, pos: number) {
|
||||
const builder = new RangeSetBuilder<Decoration>()
|
||||
const line = view.state.doc.lineAt(pos)
|
||||
|
||||
builder.add(pos, pos, Decoration.line({ class: "animate-completion" }))
|
||||
builder.finish()
|
||||
|
||||
zzfx(...[0.8, , 617, 0.02, 0.03, 0.18, , 3, -0.1, , 358, 0.06, -0.01, , , , 0.02, 1.09, 0.05, 0.02, -1213]) // Pickup 58 - Mutation 5
|
||||
}
|
||||
|
|
@ -83,11 +83,13 @@ export const todoDecorations = (filterRef: RefObject<string>) => {
|
|||
const decorationsFor = (todo: Todo) => {
|
||||
const decorations: { type: string; start: number; end: number }[] = []
|
||||
|
||||
let start = 0
|
||||
for (const node of todo.nodes) {
|
||||
const { type, start } = node
|
||||
const end = node.start + node.content.length
|
||||
const { type } = node
|
||||
const end = start + node.content.length
|
||||
|
||||
decorations.push({ type, start, end })
|
||||
start = end
|
||||
}
|
||||
|
||||
return decorations
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { keymap as viewKeymap } from "@codemirror/view" // ensure keymap is impo
|
|||
import { foldGutter, foldKeymap } from "@codemirror/language"
|
||||
import { refreshFilterEffect, todoDecorations } from "@/todoDecorations"
|
||||
import { autoTodoOnNewline } from "@/autoTodoOnNewline"
|
||||
import { todoKeymap } from "@/todoKeymap"
|
||||
import { buildKeyBindings, todoKeymap } from "@/todoKeymap"
|
||||
import { todoClickHandler } from "@/todoClickHandler"
|
||||
import { dateAutocompletion } from "@/dateAutocompletion"
|
||||
import { todoTimer } from "@/todoTimer"
|
||||
|
|
@ -25,6 +25,7 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => {
|
|||
const [filter, setFilter] = useState<string>("")
|
||||
const filterRef = useRef(filter)
|
||||
const filterElRef = useRef<HTMLInputElement>(null)
|
||||
const [showShortcuts, setShowShortcuts] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
filterRef.current = filter
|
||||
|
|
@ -41,7 +42,6 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => {
|
|||
}
|
||||
})
|
||||
|
||||
// Use todos prop for content
|
||||
const state = EditorState.create({
|
||||
doc: defaultValue || defaultDoc,
|
||||
extensions: [
|
||||
|
|
@ -51,7 +51,7 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => {
|
|||
todoDecorations(filterRef),
|
||||
changeListener,
|
||||
autoTodoOnNewline,
|
||||
todoKeymap(filterElRef),
|
||||
todoKeymap(filterElRef, setShowShortcuts),
|
||||
todoClickHandler,
|
||||
dateAutocompletion,
|
||||
todoTimer,
|
||||
|
|
@ -66,7 +66,22 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => {
|
|||
editorRef.current = view
|
||||
|
||||
return () => view.destroy()
|
||||
}, [onChange])
|
||||
}, [onChange, setShowShortcuts])
|
||||
|
||||
// Handle escape key globally to close shortcuts modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "Escape" && showShortcuts) {
|
||||
setShowShortcuts(false)
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
if (showShortcuts) {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [showShortcuts])
|
||||
|
||||
const filterInput = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === "Escape") {
|
||||
|
|
@ -80,6 +95,7 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => {
|
|||
|
||||
return (
|
||||
<div class="grow flex flex-col">
|
||||
{showShortcuts && <KeyboardShortcuts onClose={() => setShowShortcuts(false)} />}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by tag"
|
||||
|
|
@ -94,18 +110,44 @@ export const TodoEditor = ({ defaultValue, onChange }: TodoEditorProps) => {
|
|||
)
|
||||
}
|
||||
|
||||
const KeyboardShortcuts = ({ onClose }: { onClose: () => void }) => {
|
||||
return (
|
||||
<div class="fixed inset-0 bg-black bg-opacity-30 z-50" onClick={onClose}>
|
||||
<div class="fixed inset-0 flex items-center justify-center">
|
||||
<div class="bg-white rounded-sm p-3 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<ul class="grid grid-cols-1 gap-2 text-xs">
|
||||
{buildKeyBindings(undefined as any)
|
||||
.filter((k) => !k.hidden)
|
||||
.map((binding) => (
|
||||
<li class="grid grid-cols-2 items-center gap-4">
|
||||
<span>{binding.label}</span>
|
||||
<div class="flex gap-1 justify-end">
|
||||
{binding.key.split("-").map((part) => (
|
||||
<kbd class="bg-gray-200 px-2 py-1 rounded">{part}</kbd>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultDoc = `
|
||||
# Today (Group tasks by when they are due)
|
||||
# Today
|
||||
|
||||
- [ ] Sample task with a due date @${DateTime.local().toFormat("MM/dd/yyyy")}
|
||||
- [ ] You can use a #tag to filter tasks
|
||||
- [ ] A sub task! Create nested tasks by indenting with <tab>
|
||||
- [x] Complete a task by pressing <opt+k>
|
||||
- [ ] You can use a #tag to filter todos
|
||||
- [ ] A sub task! Create nested todos by indenting with <tab>
|
||||
- [x] Complete a todo by pressing <opt+k>
|
||||
- [ ] You can also set a time estimate for todos (press the play button to start) 10m
|
||||
|
||||
# This week
|
||||
- [ ] Another task with a due date @${DateTime.local().plus({ days: 3 }).toFormat("MM/dd/yyyy")}
|
||||
- [ ] Another todo with a due date @${DateTime.local().plus({ days: 3 }).toFormat("MM/dd/yyyy")}
|
||||
|
||||
# Later
|
||||
- [ ] I use later as a junk drawer for tasks I don't want to forget
|
||||
- [ ] I use later as a junk drawer for todos I don't want to forget
|
||||
|
||||
`.trim()
|
||||
|
|
|
|||
|
|
@ -1,50 +1,183 @@
|
|||
import { indentMore, indentLess } from "@codemirror/commands"
|
||||
import { EditorView, keymap } from "@codemirror/view"
|
||||
import { EditorView, keymap, type KeyBinding } from "@codemirror/view"
|
||||
import { Todo } from "./todo"
|
||||
import { type RefObject } from "hono/jsx"
|
||||
import { parseTodoList, todoListToString } from "@/todoList"
|
||||
import { todoTimer } from "./todoTimer"
|
||||
import { triggerTodoCompletionEffect } from "./todoCompletion"
|
||||
|
||||
export const todoKeymap = (filterElRef: RefObject<HTMLInputElement>) => {
|
||||
return keymap.of([
|
||||
{
|
||||
key: "alt-l", // Focus the filter input
|
||||
preventDefault: true,
|
||||
run: (_view: EditorView) => {
|
||||
filterElRef.current?.focus()
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "alt-k", // toggle done state of the todo item
|
||||
preventDefault: true,
|
||||
run: (view: EditorView) => {
|
||||
const { state } = view
|
||||
const { head } = state.selection.main
|
||||
const line = state.doc.lineAt(head)
|
||||
export type Command = KeyBinding & { label: string; key: string; hidden?: boolean }
|
||||
|
||||
const todo = Todo.parse(line.text)
|
||||
if (!todo) return false
|
||||
todo.done = !todo.done
|
||||
export const buildKeyBindings = (
|
||||
filterElRef: RefObject<HTMLInputElement>,
|
||||
setShowShortcuts?: (show: boolean) => void
|
||||
): Command[] => [
|
||||
{
|
||||
label: "Insert empty",
|
||||
key: "alt-h",
|
||||
preventDefault: true,
|
||||
run: (view: EditorView) => {
|
||||
// if the line is empty, insert a new todo item
|
||||
const { state } = view
|
||||
const { head } = state.selection.main
|
||||
const line = state.doc.lineAt(head)
|
||||
|
||||
const from = line.from
|
||||
const to = line.to
|
||||
const offset = head - from
|
||||
if (line.text.trim() === "") {
|
||||
const newTodo = "- [ ] "
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: todo.toString() },
|
||||
selection: { anchor: from + offset },
|
||||
changes: { from: line.from, to: line.to, insert: newTodo },
|
||||
selection: { anchor: line.to + newTodo.length },
|
||||
userEvent: "input",
|
||||
})
|
||||
} else {
|
||||
// otherwise insert a new empty line
|
||||
view.dispatch({
|
||||
changes: { from: head, to: head, insert: "\n" },
|
||||
selection: { anchor: head + 1 },
|
||||
userEvent: "input",
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Show keyboard shortcuts",
|
||||
key: "alt-/",
|
||||
preventDefault: true,
|
||||
run: (_view: EditorView) => {
|
||||
setShowShortcuts?.(true)
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Toggle done state of the todo item",
|
||||
key: "alt-j",
|
||||
preventDefault: true,
|
||||
run: (view: EditorView) => toggleDone(view),
|
||||
},
|
||||
{
|
||||
label: "Toggle the todo timer",
|
||||
key: "alt-Enter",
|
||||
preventDefault: true,
|
||||
run: (view: EditorView) => {
|
||||
const timerPlugin = view.plugin(todoTimer)
|
||||
if (timerPlugin?.currentWidget) {
|
||||
timerPlugin.currentWidget.toggleTimer()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return false
|
||||
},
|
||||
{
|
||||
key: "Tab",
|
||||
preventDefault: true,
|
||||
run: indentMore,
|
||||
},
|
||||
{
|
||||
label: "Move all done todos to # Done",
|
||||
key: "alt-k",
|
||||
preventDefault: true,
|
||||
run: (view: EditorView) => {
|
||||
moveToDone(view)
|
||||
return true
|
||||
},
|
||||
{
|
||||
key: "Shift-Tab",
|
||||
preventDefault: true,
|
||||
run: indentLess,
|
||||
},
|
||||
{
|
||||
label: "Stop the todo timer",
|
||||
key: "Escape",
|
||||
hidden: true,
|
||||
run: (view: EditorView) => {
|
||||
const timerPlugin = view.plugin(todoTimer)
|
||||
if (timerPlugin?.currentWidget) {
|
||||
timerPlugin.currentWidget.stopTimer()
|
||||
}
|
||||
return false
|
||||
},
|
||||
])
|
||||
},
|
||||
{
|
||||
label: "Focus the filter input",
|
||||
key: "alt-l",
|
||||
preventDefault: true,
|
||||
run: (_view: EditorView) => {
|
||||
filterElRef.current?.focus()
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Indent the todo item",
|
||||
key: "Tab",
|
||||
preventDefault: true,
|
||||
run: indentMore,
|
||||
},
|
||||
{
|
||||
label: "Unindent the todo item",
|
||||
key: "Shift-Tab",
|
||||
preventDefault: true,
|
||||
run: indentLess,
|
||||
},
|
||||
]
|
||||
|
||||
export const todoKeymap = (
|
||||
filterElRef: RefObject<HTMLInputElement>,
|
||||
setShowShortcuts?: (show: boolean) => void
|
||||
) => {
|
||||
return keymap.of(buildKeyBindings(filterElRef, setShowShortcuts))
|
||||
}
|
||||
|
||||
const moveToDone = (view: EditorView) => {
|
||||
const todoList = parseTodoList(view.state.doc.toString())
|
||||
let doneHeader = todoList.find((header) => header.title === "# Done")
|
||||
|
||||
if (!doneHeader) {
|
||||
doneHeader = { title: "# Done", todos: [] }
|
||||
todoList.push(doneHeader)
|
||||
}
|
||||
|
||||
// collect and remove all done todos
|
||||
const doneTodos: Todo[] = []
|
||||
todoList.forEach((header) => {
|
||||
header.todos = header.todos.filter((todo) => {
|
||||
if (typeof todo === "string" || !todo.done) return true
|
||||
|
||||
doneTodos.push(todo)
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
doneHeader.todos.push(...doneTodos)
|
||||
|
||||
const updatedList = todoListToString(todoList).trimEnd()
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: updatedList },
|
||||
selection: { anchor: 0 },
|
||||
userEvent: "input",
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDone = (view: EditorView) => {
|
||||
const { state } = view
|
||||
const { head } = state.selection.main
|
||||
const line = state.doc.lineAt(head)
|
||||
|
||||
const todo = Todo.parse(line.text)
|
||||
if (!todo) return false
|
||||
|
||||
todo.done = !todo.done
|
||||
|
||||
const timerPlugin = view.plugin(todoTimer)
|
||||
if (timerPlugin?.currentWidget) {
|
||||
timerPlugin.currentWidget.stopTimer()
|
||||
}
|
||||
|
||||
const from = line.from
|
||||
const to = line.to
|
||||
const offset = head - from
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: todo.toString() },
|
||||
selection: { anchor: from + offset },
|
||||
userEvent: "input",
|
||||
})
|
||||
|
||||
// Add animation and sound effect when completing (not uncompleting)
|
||||
if (todo.done) {
|
||||
triggerTodoCompletionEffect(view, line.from)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { todoListToString, type TodoList } from "@/todoList"
|
||||
import { parseTodoList, todoListToString, type TodoList } from "@/todoList"
|
||||
import { test, expect } from "bun:test"
|
||||
|
||||
test("todoListToString", () => {
|
||||
const todoList: TodoList = [
|
||||
{
|
||||
title: "Today",
|
||||
title: "# Today",
|
||||
todos: [
|
||||
{ done: false, indent: "", text: "a task with a #tag", children: [] },
|
||||
{ done: true, indent: "", text: "a #tag and date @2/2/25 in a task", children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tomorrow",
|
||||
title: "# Tomorrow",
|
||||
todos: [
|
||||
{
|
||||
done: false,
|
||||
|
|
@ -41,3 +41,47 @@ test("todoListToString", () => {
|
|||
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
test("parseTodoList", () => {
|
||||
const todoListString = `# Today
|
||||
- [ ] a task with a #tag
|
||||
- [x] a #tag and date @2/2/25 in a task
|
||||
|
||||
# Tomorrow
|
||||
- [ ] another task
|
||||
- [ ] a subtask
|
||||
- [x] completed
|
||||
|
||||
- [ ] a task with a @1/2/25 date`
|
||||
|
||||
const result = parseTodoList(todoListString)
|
||||
|
||||
const expected: TodoList = [
|
||||
{
|
||||
title: "# Today",
|
||||
todos: [
|
||||
{ done: false, indent: "", text: "a task with a #tag", children: [] },
|
||||
{ done: true, indent: "", text: "a #tag and date @2/2/25 in a task", children: [] },
|
||||
"",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "# Tomorrow",
|
||||
todos: [
|
||||
{
|
||||
done: false,
|
||||
indent: "",
|
||||
text: "another task",
|
||||
children: [
|
||||
{ done: false, indent: " ", text: "a subtask", children: [] },
|
||||
{ done: true, indent: " ", text: "completed", children: [] },
|
||||
],
|
||||
},
|
||||
" ",
|
||||
{ done: false, indent: "", text: "a task with a @1/2/25 date", children: [] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual(expected)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,33 +1,71 @@
|
|||
import type { TodoJSON } from "@/todo"
|
||||
import { Todo, type TodoJSON } from "@/todo"
|
||||
import { ensure } from "@workshop/shared/utils"
|
||||
|
||||
export type TodoList = {
|
||||
type TodoHeader = {
|
||||
title?: string
|
||||
todos: TodoOrString[]
|
||||
}[]
|
||||
todos: (Todo | string)[]
|
||||
}
|
||||
|
||||
export type TodoOrString = TodoJSON | string
|
||||
export type TodoList = TodoHeader[]
|
||||
|
||||
export const parseTodoList = (input: string): TodoList => {
|
||||
const lines = input.split("\n")
|
||||
const todoList: TodoList = []
|
||||
let currentHeader: TodoHeader = { todos: [] }
|
||||
let parentStack: Todo[] = []
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (line.startsWith("#")) {
|
||||
currentHeader = { title: line, todos: [] }
|
||||
todoList.push(currentHeader)
|
||||
} else {
|
||||
const todo = Todo.parse(line)
|
||||
|
||||
if (todo) {
|
||||
for (let i = parentStack.length - 1; i >= 0; i--) {
|
||||
const parent = parentStack[i]
|
||||
ensure(parent, `Parent at index ${i} should not be undefined`)
|
||||
|
||||
if (todo.indent.length > parent.indent.length) {
|
||||
parent.addChild(todo)
|
||||
parentStack.push(todo)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
currentHeader.todos.push(todo)
|
||||
parentStack = [todo]
|
||||
return
|
||||
} else {
|
||||
currentHeader.todos.push(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return todoList
|
||||
}
|
||||
|
||||
export const todoListToString = (todoList: TodoList): string => {
|
||||
let output = ""
|
||||
|
||||
todoList.forEach((header) => {
|
||||
if (header.title) {
|
||||
output += `# ${header.title}\n`
|
||||
output += `${header.title}\n`
|
||||
}
|
||||
|
||||
header.todos.forEach((todoJson) => {
|
||||
output += todoJsonToString(todoJson)
|
||||
header.todos.forEach((todo) => {
|
||||
if (typeof todo === "string") {
|
||||
output += `${todo}\n`
|
||||
} else {
|
||||
output += todoJsonToString(todo)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const todoJsonToString = (todoOrString: TodoOrString): string => {
|
||||
if (typeof todoOrString === "string") {
|
||||
return `${todoOrString}\n`
|
||||
}
|
||||
|
||||
const todoJsonToString = (todoOrString: Todo): string => {
|
||||
let result = `${todoOrString.indent}- [${todoOrString.done ? "x" : " "}] ${todoOrString.text}\n`
|
||||
|
||||
todoOrString.children.forEach((child) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { EditorView, ViewPlugin, ViewUpdate, WidgetType, Decoration } from "@codemirror/view"
|
||||
import { RangeSetBuilder } from "@codemirror/state"
|
||||
import { Todo } from "@/todo"
|
||||
import { zzfx } from "zzfx"
|
||||
|
||||
class CountdownWidget extends WidgetType {
|
||||
duration: number
|
||||
|
|
@ -24,17 +25,31 @@ class CountdownWidget extends WidgetType {
|
|||
}
|
||||
}
|
||||
|
||||
stopTimer() {
|
||||
this.view.dom.classList.remove("timer-running")
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId)
|
||||
this.intervalId = undefined
|
||||
}
|
||||
this.startedAt = undefined
|
||||
}
|
||||
|
||||
toggleTimer() {
|
||||
if (this.startedAt) {
|
||||
this.updateDOM(this.toDOM(), this.view)
|
||||
this.stopTimer()
|
||||
} else {
|
||||
this.startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const span = document.createElement("span")
|
||||
span.className = "countdown-timer"
|
||||
|
||||
span.addEventListener("mousedown", (e) => {
|
||||
if (this.startedAt) {
|
||||
this.stopTimer()
|
||||
span.textContent = "▶️"
|
||||
} else {
|
||||
this.startTimer()
|
||||
}
|
||||
this.toggleTimer()
|
||||
})
|
||||
|
||||
const updateTimer = () => {
|
||||
|
|
@ -45,7 +60,7 @@ class CountdownWidget extends WidgetType {
|
|||
}
|
||||
|
||||
if (!this.startedAt) {
|
||||
span.textContent = "▶️"
|
||||
span.textContent = "▶"
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -53,13 +68,21 @@ class CountdownWidget extends WidgetType {
|
|||
const remaining = this.duration - elapsed
|
||||
|
||||
if (remaining <= 0) {
|
||||
span.textContent = "⏰ TIME'S UP!"
|
||||
|
||||
span.textContent = "00:00"
|
||||
span.classList.remove("running")
|
||||
span.classList.add("finished")
|
||||
this.stopTimer()
|
||||
zzfx(...[1.2, , 117, 0.03, 0.01, 0.12, , 2.1, , , , , , 1.3, , 0.3, 0.08, 0.6, 0.1, , 1946]) // Hit 107
|
||||
this.showNotification()
|
||||
return
|
||||
}
|
||||
|
||||
const seconds = Math.ceil(remaining / 1000)
|
||||
span.textContent = `${seconds.toString().padStart(2, "0")}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secondsRemainder = seconds % 60
|
||||
span.textContent = `${minutes.toString().padStart(2, "0")}:${secondsRemainder
|
||||
.toString()
|
||||
.padStart(2, "0")}`
|
||||
}
|
||||
|
||||
updateTimer()
|
||||
|
|
@ -68,19 +91,6 @@ class CountdownWidget extends WidgetType {
|
|||
return span
|
||||
}
|
||||
|
||||
stopTimer() {
|
||||
this.view.dom.classList.remove("timer-running")
|
||||
|
||||
this.playAlert()
|
||||
this.showNotification()
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId)
|
||||
this.intervalId = undefined
|
||||
}
|
||||
this.startedAt = undefined
|
||||
}
|
||||
|
||||
private async showNotification() {
|
||||
if (Notification.permission === "default") {
|
||||
console.log("Requesting notification permission...")
|
||||
|
|
@ -99,45 +109,6 @@ class CountdownWidget extends WidgetType {
|
|||
}
|
||||
}
|
||||
|
||||
private playAlert() {
|
||||
try {
|
||||
// Create a more noticeable beep with vibrato
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const oscillator = audioContext.createOscillator()
|
||||
const gainNode = audioContext.createGain()
|
||||
const vibratoOscillator = audioContext.createOscillator()
|
||||
const vibratoGain = audioContext.createGain()
|
||||
|
||||
// Set up vibrato (frequency modulation)
|
||||
vibratoOscillator.frequency.setValueAtTime(6, audioContext.currentTime) // 6Hz vibrato
|
||||
vibratoGain.gain.setValueAtTime(30, audioContext.currentTime) // Vibrato depth
|
||||
vibratoOscillator.connect(vibratoGain)
|
||||
vibratoGain.connect(oscillator.frequency)
|
||||
|
||||
// Main oscillator setup
|
||||
oscillator.connect(gainNode)
|
||||
gainNode.connect(audioContext.destination)
|
||||
|
||||
// Higher frequency for more attention-grabbing sound
|
||||
oscillator.frequency.setValueAtTime(1000, audioContext.currentTime)
|
||||
oscillator.type = "sine"
|
||||
|
||||
// Louder volume and longer duration (3 seconds)
|
||||
gainNode.gain.setValueAtTime(0.5, audioContext.currentTime)
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 3)
|
||||
|
||||
// Start both oscillators
|
||||
vibratoOscillator.start(audioContext.currentTime)
|
||||
oscillator.start(audioContext.currentTime)
|
||||
|
||||
// Stop after 3 seconds
|
||||
vibratoOscillator.stop(audioContext.currentTime + 3)
|
||||
oscillator.stop(audioContext.currentTime + 3)
|
||||
} catch (error) {
|
||||
console.log("Could not play alert sound:", error)
|
||||
}
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId)
|
||||
|
|
@ -153,16 +124,6 @@ export const todoTimer = ViewPlugin.fromClass(
|
|||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view)
|
||||
this.setupKeyHandler(view)
|
||||
}
|
||||
|
||||
setupKeyHandler(view: EditorView) {
|
||||
view.dom.addEventListener("keydown", (e) => {
|
||||
if (e.altKey && e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
this.currentWidget?.startTimer()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
|
|
@ -177,13 +138,13 @@ export const todoTimer = ViewPlugin.fromClass(
|
|||
const { doc, selection } = view.state
|
||||
const line = doc.lineAt(selection.main.head)
|
||||
|
||||
builder.add(line.from, line.from, Decoration.line({ class: "neat" }))
|
||||
|
||||
const todo = Todo.parse(line.text)
|
||||
if (!todo?.timeEstimate) {
|
||||
if (!todo?.timeEstimate || todo.done) {
|
||||
this.currentWidget = undefined
|
||||
return builder.finish()
|
||||
}
|
||||
|
||||
builder.add(line.from, line.from, Decoration.line({ class: "cm-timer" }))
|
||||
this.currentWidget = new CountdownWidget(todo.timeEstimate, view)
|
||||
const decoration = Decoration.widget({ widget: this.currentWidget, side: 1 })
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user