Skip to content

Commit 1153dd7

Browse files
authored
Tasks (#24)
* pressing t will toggle whether or not a node is a task * tasks properly move to the top of the list and are navigable * update bundle
1 parent eb3b765 commit 1153dd7

11 files changed

Lines changed: 483 additions & 45 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "outline-browser",
3-
"version": "0.0.2",
3+
"version": "0.0.3",
44
"scripts": {
55
"dev": "npx serve public",
66
"webpack-local": "ENVIRONMENT=development npx webpack -w",

public/assets/bundle.js

Lines changed: 220 additions & 14 deletions
Large diffs are not rendered by default.

public/assets/style.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,12 @@ code {
257257
color: var(--color-hidden-cursor-text);
258258
}
259259

260+
.task-checkbox {
261+
float: left;
262+
margin-right: 0.5rem;
263+
margin-top: 0.4rem;
264+
}
265+
260266
/* Theme Selector Modal */
261267
.theme-selector {
262268
min-height: 200px;

src/keyboard-shortcuts/all.ts

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
1-
import {KeyEventDefinition} from "./base";
1+
import { KeyEventDefinition } from "./base";
22
import { sidebarToggle } from "./sidebar-toggle";
33
import { j } from './j';
4-
import {k} from "./k";
5-
import {l} from './l';
6-
import {h} from "./h";
7-
import {z} from "./z";
8-
import {$} from "./$";
9-
import {i} from "./i";
10-
import {archive} from "./archive";
11-
import {tab} from "./tab";
12-
import {enter} from "./enter";
13-
import {d} from "./delete";
14-
import {lift} from "./lift";
15-
import {lower} from "./lower";
16-
import {swapUp} from "./swap-up";
17-
import {swapDown} from "./swap-down";
18-
import {escapeEditing} from "./escape-editing";
19-
import {themeSelector} from "./theme-selector";
4+
import { k } from "./k";
5+
import { l } from './l';
6+
import { h } from "./h";
7+
import { z } from "./z";
8+
import { $ } from "./$";
9+
import { i } from "./i";
10+
import { archive } from "./archive";
11+
import { tab } from "./tab";
12+
import { enter } from "./enter";
13+
import { d } from "./delete";
14+
import { lift } from "./lift";
15+
import { lower } from "./lower";
16+
import { swapUp } from "./swap-up";
17+
import { swapDown } from "./swap-down";
18+
import { escapeEditing } from "./escape-editing";
19+
import { themeSelector } from "./theme-selector";
20+
import { t } from './t';
2021

2122
export const AllShortcuts: KeyEventDefinition[] = [
2223
sidebarToggle,
@@ -26,6 +27,7 @@ export const AllShortcuts: KeyEventDefinition[] = [
2627
l,
2728
h,
2829
z,
30+
t,
2931
$,
3032
i,
3133
archive,

src/keyboard-shortcuts/archive.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,80 @@ import { KeyEventDefinition } from "./base";
22

33
export const archive: KeyEventDefinition = {
44
context: 'navigation',
5-
keys: ['shift + x'],
6-
description: 'Mark a node and all its children as "archived"',
5+
keys: ['shift + x', 'ctrl + x'],
6+
description: 'Mark a node as archived; ctrl+x also completes task',
77
action: async args => {
88
const { e, outline, cursor, api } = args;
99
e.preventDefault();
1010
// toggle "strikethrough" of node
1111
cursor.get().classList.toggle('strikethrough');
12-
outline.getContentNode(cursor.getIdOfNode()).toggleArchiveStatus();
13-
api.saveContentNode(outline.getContentNode(cursor.getIdOfNode()));
12+
const node = outline.getContentNode(cursor.getIdOfNode());
13+
node.toggleArchiveStatus();
14+
15+
if (node.task) {
16+
if (e.ctrlKey || node.archived) {
17+
node.markComplete();
18+
}
19+
else {
20+
node.markIncomplete();
21+
}
22+
}
23+
24+
// re-render content to reflect completion checkbox for the currently focused node (tasks aggregate)
25+
const contentEl = cursor.get().querySelector('.nodeContent') as HTMLElement;
26+
contentEl.innerHTML = await outline.renderContent(cursor.getIdOfNode());
27+
28+
// Also update the original outline node's content and strikethrough state
29+
const nodeId = cursor.getIdOfNode();
30+
const originalNodes = Array.from(document.querySelectorAll(`.node[data-id="${nodeId}"]`)) as HTMLElement[];
31+
const original = originalNodes.find(n => !n.closest('#id-tasks-aggregate'));
32+
if (original) {
33+
const originalContentEl = original.querySelector('.nodeContent') as HTMLElement;
34+
if (originalContentEl) {
35+
originalContentEl.innerHTML = await outline.renderContent(nodeId);
36+
}
37+
const isCompletedTask = !!node.completionDate;
38+
if (node.isArchived() || isCompletedTask) {
39+
original.classList.add('strikethrough');
40+
}
41+
else {
42+
original.classList.remove('strikethrough');
43+
}
44+
}
45+
46+
// Keep tasklist in sync for incremental updates without full render
47+
if (node.task) {
48+
outline.tasklist[nodeId] = node;
49+
}
50+
else {
51+
delete outline.tasklist[nodeId];
52+
}
53+
54+
// Refresh Tasks aggregate at the top
55+
const tasksHtml = await outline.renderTasksFromTasklist();
56+
const tasksContainer = document.getElementById('id-tasks-aggregate');
57+
if (tasksHtml.length === 0) {
58+
if (tasksContainer) {
59+
tasksContainer.remove();
60+
}
61+
}
62+
else {
63+
if (tasksContainer) {
64+
tasksContainer.outerHTML = tasksHtml;
65+
}
66+
else {
67+
const root = document.querySelector('#outliner');
68+
if (root) {
69+
root.insertAdjacentHTML('afterbegin', tasksHtml);
70+
}
71+
}
72+
}
73+
74+
// Keep cursor where the user was interacting: tasks aggregate vs main outline
75+
const inTasksAggregate = !!cursor.get()?.closest('#id-tasks-aggregate');
76+
cursor.set(inTasksAggregate ? `#tasks-id-${nodeId}` : `#id-${nodeId}`);
77+
78+
api.saveContentNode(node);
1479
api.save(outline);
1580

1681
}

src/keyboard-shortcuts/j.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ export const j: KeyEventDefinition = {
77
action: async args => {
88
// move cursor down
99
// if shift key is held, swap the node with its next sibling
10-
const sibling = args.cursor.get().nextElementSibling;
10+
const el = args.cursor.get();
11+
const sibling = el.nextElementSibling as HTMLElement | null;
1112

1213
if (sibling) {
1314
if (!args.e.shiftKey) {
14-
args.cursor.set(`#id-${sibling.getAttribute('data-id')}`);
15+
const isTasksContainer = el.id === 'id-tasks-aggregate';
16+
const inTasksAggregate = !!el.closest('#id-tasks-aggregate') && !isTasksContainer;
17+
const prefix = inTasksAggregate ? '#tasks-id-' : '#id-';
18+
args.cursor.set(`${prefix}${sibling.getAttribute('data-id')}`);
1519
}
1620
}
1721

src/keyboard-shortcuts/k.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ export const k: KeyEventDefinition = {
88
const { cursor, e } = args;
99
// move cursor up
1010
// if shift key is held, swap the node with its previous sibling
11-
const sibling = cursor.get().previousElementSibling;
11+
const el = cursor.get();
12+
const sibling = el.previousElementSibling as HTMLElement | null;
1213

1314
if (sibling && !sibling.classList.contains('nodeContent')) {
1415
if (!e.shiftKey) {
15-
cursor.set(`#id-${sibling.getAttribute('data-id')}`);
16+
const isTasksContainer = el.id === 'id-tasks-aggregate';
17+
const inTasksAggregate = !!el.closest('#id-tasks-aggregate') && !isTasksContainer;
18+
const prefix = inTasksAggregate ? '#tasks-id-' : '#id-';
19+
cursor.set(`${prefix}${sibling.getAttribute('data-id')}`);
1620
}
1721
}
1822
}

src/keyboard-shortcuts/l.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ export const l: KeyEventDefinition = {
1010
if (cursor.isNodeCollapsed()) {
1111
return;
1212
}
13-
const children = cursor.get().querySelector('.node');
13+
const el = cursor.get();
14+
const children = el.querySelector('.node') as HTMLElement | null;
1415
if (children) {
15-
cursor.set(`#id-${children.getAttribute('data-id')}`);
16+
const isTasksContainer = el.id === 'id-tasks-aggregate';
17+
const inTasksAggregate = !!el.closest('#id-tasks-aggregate') && !isTasksContainer;
18+
const prefix = inTasksAggregate || isTasksContainer ? '#tasks-id-' : '#id-';
19+
cursor.set(`${prefix}${children.getAttribute('data-id')}`);
1620
}
1721
}
1822
}

src/keyboard-shortcuts/t.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { KeyEventDefinition } from './base';
2+
3+
export const t: KeyEventDefinition = {
4+
context: 'navigation',
5+
keys: ['t'],
6+
description: 'Toggle current node as a task',
7+
action: async args => {
8+
const { e, cursor, outline, api } = args;
9+
e.preventDefault();
10+
11+
const nodeId = cursor.getIdOfNode();
12+
const node = outline.getContentNode(nodeId);
13+
node.toggleTask();
14+
15+
// Re-render just the content for this node to show/hide checkbox
16+
const contentEl = cursor.get().querySelector('.nodeContent') as HTMLElement;
17+
contentEl.innerHTML = await outline.renderContent(nodeId);
18+
19+
// Also update the original outline node's content and strikethrough state
20+
const originalNodes = Array.from(document.querySelectorAll(`.node[data-id="${nodeId}"]`)) as HTMLElement[];
21+
const original = originalNodes.find(n => !n.closest('#id-tasks-aggregate'));
22+
if (original) {
23+
const originalContentEl = original.querySelector('.nodeContent') as HTMLElement;
24+
if (originalContentEl) {
25+
originalContentEl.innerHTML = await outline.renderContent(nodeId);
26+
}
27+
const isCompletedTask = !!node.completionDate;
28+
if (node.isArchived() || isCompletedTask) {
29+
original.classList.add('strikethrough');
30+
}
31+
else {
32+
original.classList.remove('strikethrough');
33+
}
34+
}
35+
36+
// Keep tasklist in sync for incremental updates without full render
37+
if (node.task) {
38+
outline.tasklist[nodeId] = node;
39+
}
40+
else {
41+
delete outline.tasklist[nodeId];
42+
}
43+
44+
// Refresh Tasks aggregate at the top
45+
const tasksHtml = await outline.renderTasksFromTasklist();
46+
const tasksContainer = document.getElementById('id-tasks-aggregate');
47+
if (tasksHtml.length === 0) {
48+
if (tasksContainer) {
49+
tasksContainer.remove();
50+
}
51+
}
52+
else {
53+
if (tasksContainer) {
54+
tasksContainer.outerHTML = tasksHtml;
55+
}
56+
else {
57+
const root = document.querySelector('#outliner');
58+
if (root) {
59+
root.insertAdjacentHTML('afterbegin', tasksHtml);
60+
}
61+
}
62+
}
63+
64+
// Keep cursor where the user was interacting: tasks aggregate vs main outline
65+
const inTasksAggregate = !!cursor.get()?.closest('#id-tasks-aggregate');
66+
cursor.set(inTasksAggregate ? `#tasks-id-${nodeId}` : `#id-${nodeId}`);
67+
68+
api.saveContentNode(node);
69+
api.save(outline);
70+
}
71+
}
72+

src/lib/contentNode.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface IContentNode {
88
archiveDate?: number;
99
deleted: boolean;
1010
deletedDate?: number;
11+
task?: boolean;
12+
completionDate?: number;
1113
}
1214

1315
export class ContentNode implements IContentNode {
@@ -20,6 +22,8 @@ export class ContentNode implements IContentNode {
2022
archiveDate?: number;
2123
deleted: boolean;
2224
deletedDate?: number;
25+
task?: boolean;
26+
completionDate?: number;
2327

2428
constructor(id?: string, content?: string) {
2529
this.id = id;
@@ -29,6 +33,9 @@ export class ContentNode implements IContentNode {
2933

3034
this.archived = false;
3135
this.deleted = false;
36+
37+
this.task = false;
38+
this.completionDate = null;
3239
}
3340

3441
static Create(data: IContentNode): ContentNode {
@@ -42,6 +49,10 @@ export class ContentNode implements IContentNode {
4249
node.deleted = data.deleted;
4350
node.deletedDate = data.deletedDate;
4451

52+
// Backwards compatibility with saved data that may not have these fields
53+
node.task = (data as any).task ?? false;
54+
node.completionDate = (data as any).completionDate ?? null;
55+
4556
return node;
4657
}
4758

@@ -87,6 +98,26 @@ export class ContentNode implements IContentNode {
8798
this.deletedDate = Date.now();
8899
}
89100

101+
// Task helpers
102+
toggleTask() {
103+
if (this.task) {
104+
this.task = false;
105+
this.completionDate = null;
106+
}
107+
else {
108+
this.task = true;
109+
}
110+
}
111+
112+
markComplete() {
113+
this.task = true;
114+
this.completionDate = Date.now();
115+
}
116+
117+
markIncomplete() {
118+
this.completionDate = null;
119+
}
120+
90121
toJson(): IContentNode {
91122
return {
92123
id: this.id,
@@ -97,7 +128,9 @@ export class ContentNode implements IContentNode {
97128
archived: this.archived,
98129
archiveDate: this.archiveDate,
99130
deleted: this.deleted,
100-
deletedDate: this.deletedDate
131+
deletedDate: this.deletedDate,
132+
task: this.task,
133+
completionDate: this.completionDate
101134
};
102135
}
103136

0 commit comments

Comments
 (0)