refactor(document-mode): remove legacy Angular document-mode feature

Drops the in-app Angular document view (src/app/features/document-mode/)
now that the TipTap-based plugin under packages/plugin-dev/document-mode/
is the canonical implementation:

- Deletes the document-mode feature module (service, view, block
  components, sanitiser, actions).
- Removes work-view and page-title hooks for the legacy toggle.
- Drops isDocumentMode / documentBlocks fields from WorkContextCommon
  and the matching defaults; drops isDocumentModeEnabled from
  app-features config + onboarding presets.
- Strips updateDocumentBlocksLocal / updateDocumentBlocksDelta from
  project and tag reducers, and the doc-keys filter from the project
  effects' update-snack.
- Keeps the DOCUMENT_MODE_UPDATE_BLOCKS_DELTA enum + op-log code so
  historical op-log entries decode to a stable action type rather
  than the raw 'DU' code.

The bundled-plugins/document-mode path in plugin.service.ts continues
to point at the new TipTap plugin.
This commit is contained in:
Johannes Millan 2026-05-21 18:19:27 +02:00
parent 95debf535d
commit de5e399abd
25 changed files with 22 additions and 3120 deletions

View file

@ -16,7 +16,6 @@ import { TaskViewCustomizerService } from '../../../features/task-view-customize
import { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component';
import { GlobalConfigService } from '../../../features/config/global-config.service';
import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
import { DocumentModeService } from '../../../features/document-mode/document-mode.service';
@Component({
selector: 'page-title',
@ -55,37 +54,23 @@ import { DocumentModeService } from '../../../features/document-mode/document-mo
<mat-icon>more_vert</mat-icon>
</button>
@if (isWorkViewPage()) {
@if (!isDocumentMode()) {
<button
class="task-filter-btn"
[class.isCustomized]="taskViewCustomizerService.isCustomized()"
[matMenuTriggerFor]="customizerPanel.menu"
mat-icon-button
matTooltip="{{
T.GCF.KEYBOARD.TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL | translate
}} {{
kb.toggleTaskViewCustomizerPanel
? '[' + kb.toggleTaskViewCustomizerPanel + ']'
: ''
}}"
>
<mat-icon>filter_list</mat-icon>
</button>
<button
class="task-filter-btn"
[class.isCustomized]="taskViewCustomizerService.isCustomized()"
[matMenuTriggerFor]="customizerPanel.menu"
mat-icon-button
matTooltip="{{
T.GCF.KEYBOARD.TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL | translate
}} {{
kb.toggleTaskViewCustomizerPanel
? '[' + kb.toggleTaskViewCustomizerPanel + ']'
: ''
}}"
>
<mat-icon>filter_list</mat-icon>
</button>
<task-view-customizer-panel #customizerPanel></task-view-customizer-panel>
}
@if (isProjectContext() && isDocumentModeEnabled()) {
<button
class="doc-mode-btn"
mat-icon-button
(click)="documentModeService.toggleDocumentMode()"
[matTooltip]="
isDocumentMode() ? 'Switch to list view' : 'Switch to document view'
"
>
<mat-icon>{{ isDocumentMode() ? 'list' : 'article' }}</mat-icon>
</button>
}
<task-view-customizer-panel #customizerPanel></task-view-customizer-panel>
}
</div>
}
@ -191,7 +176,6 @@ export class PageTitleComponent {
private _router = inject(Router);
private _workContextService = inject(WorkContextService);
readonly taskViewCustomizerService = inject(TaskViewCustomizerService);
readonly documentModeService = inject(DocumentModeService);
private readonly _configService = inject(GlobalConfigService);
private _translateService = inject(TranslateService);
@ -202,17 +186,6 @@ export class PageTitleComponent {
activeWorkContextTypeAndId = toSignal(
this._workContextService.activeWorkContextTypeAndId$,
);
isDocumentMode = toSignal(
this._workContextService.activeWorkContext$.pipe(map((ctx) => !!ctx.isDocumentMode)),
{ initialValue: false },
);
isProjectContext = toSignal(this._workContextService.isActiveWorkContextProject$, {
initialValue: false,
});
isDocumentModeEnabled = computed(
() => this._configService.appFeatures().isDocumentModeEnabled,
);
// Single source for the current URL path — all route-derived signals compute off this.
// Query and fragment are stripped so end-anchored matchers work for e.g. `/config#plugins`.
private _url$ = this._router.events.pipe(

View file

@ -50,7 +50,8 @@ export const ACTION_TYPE_TO_CODE: Record<ActionType, string> = {
// GlobalConfig actions (C)
[ActionType.GLOBAL_CONFIG_UPDATE_SECTION]: 'CU',
// DocumentMode actions (D)
// DocumentMode actions (D) — feature removed, kept so historical op-log
// entries decode to a stable action type rather than the raw 'DU' code.
[ActionType.DOCUMENT_MODE_UPDATE_BLOCKS_DELTA]: 'DU',
// Metric actions (E)

View file

@ -28,7 +28,6 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
isEnableUserProfiles: false,
isHabitsEnabled: true,
isFinishDayEnabled: true,
isDocumentModeEnabled: true,
},
localization: {
lng: undefined,

View file

@ -114,14 +114,6 @@ export const APP_FEATURES_FORM_CFG: ConfigFormSection<AppFeaturesConfig> = {
icon: 'heart_check',
},
},
{
key: 'isDocumentModeEnabled',
type: 'slide-toggle',
templateOptions: {
label: T.GCF.APP_FEATURES.DOCUMENT_MODE,
icon: 'article',
},
},
{
key: 'isEnableUserProfiles',
type: 'slide-toggle',

View file

@ -21,7 +21,6 @@ export type AppFeaturesConfig = Readonly<{
isEnableUserProfiles: boolean;
isHabitsEnabled: boolean;
isFinishDayEnabled: boolean;
isDocumentModeEnabled: boolean;
}>;
export type MiscConfig = Readonly<{

View file

@ -1,122 +0,0 @@
export type DocumentBlockType = 'task' | 'text' | 'heading' | 'divider';
export interface DocumentBlockBase {
id: string;
type: DocumentBlockType;
}
export interface TaskBlock extends DocumentBlockBase {
type: 'task';
taskId: string;
}
export interface TextBlock extends DocumentBlockBase {
type: 'text';
content: string;
}
export type HeadingLevel = 1 | 2 | 3;
export interface HeadingBlock extends DocumentBlockBase {
type: 'heading';
content: string;
level: HeadingLevel;
}
export interface DividerBlock extends DocumentBlockBase {
type: 'divider';
}
export type DocumentBlock = TaskBlock | TextBlock | HeadingBlock | DividerBlock;
export interface DocumentBlocksDelta {
changedBlocks: DocumentBlock[];
removedBlockIds: string[];
blockOrder: string[];
}
const blocksEqual = (a: DocumentBlock, b: DocumentBlock): boolean => {
if (a.id !== b.id || a.type !== b.type) return false;
switch (a.type) {
case 'task':
return a.taskId === (b as TaskBlock).taskId;
case 'text':
return a.content === (b as TextBlock).content;
case 'heading':
return (
a.content === (b as HeadingBlock).content && a.level === (b as HeadingBlock).level
);
case 'divider':
return true;
}
};
const orderEqual = (a: string[], b: string[]): boolean =>
a.length === b.length && a.every((v, i) => v === b[i]);
/**
* Compute a minimal delta between two block arrays.
* Returns null if nothing changed.
*/
export const computeBlocksDelta = (
lastBlocks: DocumentBlock[],
currentBlocks: DocumentBlock[],
): DocumentBlocksDelta | null => {
const lastMap = new Map(lastBlocks.map((b) => [b.id, b]));
const currentIds = new Set(currentBlocks.map((b) => b.id));
const removedBlockIds = lastBlocks
.filter((b) => !currentIds.has(b.id))
.map((b) => b.id);
const changedBlocks = currentBlocks.filter((block) => {
const prev = lastMap.get(block.id);
return !prev || !blocksEqual(prev, block);
});
const blockOrder = currentBlocks.map((b) => b.id);
const lastOrder = lastBlocks.map((b) => b.id);
const orderChanged = !orderEqual(lastOrder, blockOrder);
if (changedBlocks.length === 0 && removedBlockIds.length === 0 && !orderChanged) {
return null;
}
return { changedBlocks, removedBlockIds, blockOrder };
};
/**
* Apply a delta to an existing block array.
* Used by reducers to reconstruct documentBlocks from a delta operation.
* Preserves blocks not in blockOrder (e.g. concurrent remote adds).
*/
export const applyDocumentBlocksDelta = (
existing: DocumentBlock[],
delta: DocumentBlocksDelta,
): DocumentBlock[] => {
const blockMap = new Map(existing.map((b) => [b.id, b]));
for (const block of delta.changedBlocks) {
blockMap.set(block.id, block);
}
for (const id of delta.removedBlockIds) {
blockMap.delete(id);
}
const ordered: DocumentBlock[] = [];
const seen = new Set<string>();
for (const id of delta.blockOrder) {
const block = blockMap.get(id);
if (block) {
ordered.push(block);
seen.add(id);
}
}
// Append blocks not in order (concurrent remote adds)
for (const [id, block] of blockMap) {
if (!seen.has(id)) {
ordered.push(block);
}
}
return ordered;
};

View file

@ -1,76 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
input,
output,
viewChild,
} from '@angular/core';
import { DividerBlock } from '../document-block.model';
@Component({
selector: 'document-divider-block',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
template: `
<hr
#dividerEl
tabindex="0"
(keydown)="onKeydown($event)"
/>
`,
styles: [
`
:host {
display: block;
padding: var(--s) 0;
}
hr {
border: none;
border-top: 1px solid var(--text-color-muted);
opacity: 0.3;
margin: 0;
outline: none;
cursor: pointer;
}
hr:focus {
opacity: 0.6;
border-top-color: var(--palette-primary-500, #6495ed);
}
`,
],
})
export class DocumentDividerBlockComponent {
block = input.required<DividerBlock>();
enterPressed = output<void>();
deleteBlock = output<void>();
navigateUp = output<void>();
navigateDown = output<void>();
dividerEl = viewChild<ElementRef<HTMLHRElement>>('dividerEl');
focus(): void {
this.dividerEl()?.nativeElement.focus();
}
onKeydown(ev: KeyboardEvent): void {
if (ev.key === 'Backspace' || ev.key === 'Delete') {
ev.preventDefault();
this.deleteBlock.emit();
}
if (ev.key === 'ArrowUp') {
ev.preventDefault();
this.navigateUp.emit();
}
if (ev.key === 'ArrowDown') {
ev.preventDefault();
this.navigateDown.emit();
}
if (ev.key === 'Enter') {
ev.preventDefault();
this.enterPressed.emit();
}
}
}

View file

@ -1,326 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { HeadingBlock } from '../document-block.model';
import { sanitizeBlockHtml } from '../sanitize-block-html';
@Component({
selector: 'document-heading-block',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
template: `
@switch (block().level) {
@case (1) {
<h1
#editableEl
contenteditable="true"
[attr.data-placeholder]="'Heading 1'"
(input)="onInput($event)"
(keydown)="onKeydown($event)"
(paste)="onPaste($event)"
></h1>
}
@case (2) {
<h2
#editableEl
contenteditable="true"
[attr.data-placeholder]="'Heading 2'"
(input)="onInput($event)"
(keydown)="onKeydown($event)"
(paste)="onPaste($event)"
></h2>
}
@case (3) {
<h3
#editableEl
contenteditable="true"
[attr.data-placeholder]="'Heading 3'"
(input)="onInput($event)"
(keydown)="onKeydown($event)"
(paste)="onPaste($event)"
></h3>
}
}
`,
styles: [
`
:host {
display: block;
}
h1,
h2,
h3 {
outline: none;
cursor: text;
margin: 0;
padding: 3px 0;
font-weight: 600;
color: var(--text-color-most-intense);
word-wrap: break-word;
}
h1 {
font-size: 1.875em;
line-height: 1.3;
}
h2 {
font-size: 1.5em;
line-height: 1.35;
}
h3 {
font-size: 1.25em;
line-height: 1.4;
}
h1:empty:focus::before,
h2:empty:focus::before,
h3:empty:focus::before {
content: attr(data-placeholder);
color: var(--text-color-muted);
opacity: 0.4;
pointer-events: none;
}
`,
],
})
export class DocumentHeadingBlockComponent {
block = input.required<HeadingBlock>();
contentChanged = output<string>();
enterPressed = output<void>();
splitAtCursor = output<{ before: string; after: string }>();
backspaceOnEmpty = output<void>();
backspaceAtStart = output<string>();
navigateUp = output<number>();
navigateDown = output<number>();
editableEl = viewChild<ElementRef<HTMLElement>>('editableEl');
private _initialized = signal(false);
constructor() {
effect(() => {
const el = this.editableEl()?.nativeElement;
const content = this.block().content;
if (el) {
// Skip if this is a local edit (user is actively typing)
if (this._initialized() && document.activeElement === el) return;
if (content.includes('<')) {
el.innerHTML = sanitizeBlockHtml(content);
} else {
el.textContent = content;
}
this._initialized.set(true);
}
});
}
focus(position?: 'start' | 'end'): void {
const el = this.editableEl()?.nativeElement;
if (!el) return;
el.focus();
if (position && el.childNodes.length > 0) {
const sel = window.getSelection();
if (!sel) return;
const textNode = el.firstChild!;
if (position === 'end') {
sel.collapse(textNode, textNode.textContent?.length || 0);
} else {
sel.collapse(textNode, 0);
}
}
}
focusAtOffset(offset: number): void {
const el = this.editableEl()?.nativeElement;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel) return;
const pos = this._findNodeAtOffset(el, offset);
if (pos) {
sel.collapse(pos.node, pos.offset);
}
}
onInput(ev: Event): void {
const el = ev.target as HTMLElement;
const text = el.textContent || '';
const hasFormatting = el.querySelector('b, strong, i, em, a, u');
this.contentChanged.emit(hasFormatting ? sanitizeBlockHtml(el.innerHTML) : text);
}
onPaste(ev: ClipboardEvent): void {
ev.preventDefault();
const text = ev.clipboardData?.getData('text/plain') || '';
document.execCommand('insertText', false, text);
}
onKeydown(ev: KeyboardEvent): void {
// Inline formatting shortcuts
if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey) {
if (ev.key === 'b') {
ev.preventDefault();
document.execCommand('bold');
this._emitContent();
return;
}
if (ev.key === 'i') {
ev.preventDefault();
document.execCommand('italic');
this._emitContent();
return;
}
}
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
const el = this.editableEl()?.nativeElement;
if (!el) return;
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const fullText = el.textContent || '';
const offset = this._getTextOffset(el, range);
const before = fullText.substring(0, offset);
const after = fullText.substring(offset);
this.splitAtCursor.emit({ before, after });
} else {
this.enterPressed.emit();
}
return;
}
if (ev.key === 'Backspace' || ev.key === 'Delete') {
const el = ev.target as HTMLElement;
const text = el.textContent || '';
if (!text) {
ev.preventDefault();
this.backspaceOnEmpty.emit();
return;
}
if (ev.key === 'Backspace') {
const sel = window.getSelection();
if (sel && sel.isCollapsed && this._isAtStart(el, sel)) {
ev.preventDefault();
this.backspaceAtStart.emit(text);
return;
}
}
}
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
if (this._shouldNavigateAcrossBlocks(ev.key)) {
ev.preventDefault();
const offset = this._getCurrentOffset();
if (ev.key === 'ArrowUp') {
this.navigateUp.emit(offset);
} else {
this.navigateDown.emit(offset);
}
}
}
}
private _emitContent(): void {
const el = this.editableEl()?.nativeElement;
if (!el) return;
const hasFormatting = el.querySelector('b, strong, i, em, a, u');
this.contentChanged.emit(
hasFormatting ? sanitizeBlockHtml(el.innerHTML) : el.textContent || '',
);
}
private _getCurrentOffset(): number {
const el = this.editableEl()?.nativeElement;
if (!el) return 0;
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return 0;
return this._getTextOffset(el, sel.getRangeAt(0));
}
private _getTextOffset(container: HTMLElement, range: Range): number {
const preRange = document.createRange();
preRange.selectNodeContents(container);
preRange.setEnd(range.startContainer, range.startOffset);
return preRange.toString().length;
}
private _findNodeAtOffset(
container: Node,
target: number,
): { node: Node; offset: number } | null {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let remaining = target;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const len = node.textContent?.length || 0;
if (remaining <= len) {
return { node, offset: remaining };
}
remaining -= len;
}
const lastNode = this._lastTextNode(container);
if (lastNode) {
return { node: lastNode, offset: lastNode.textContent?.length || 0 };
}
return null;
}
private _lastTextNode(container: Node): Text | null {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let last: Text | null = null;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
last = node;
}
return last;
}
private _isAtStart(el: HTMLElement, sel: Selection): boolean {
if (!sel.rangeCount) return false;
const range = sel.getRangeAt(0);
return this._getTextOffset(el, range) === 0;
}
private _shouldNavigateAcrossBlocks(key: 'ArrowUp' | 'ArrowDown'): boolean {
const el = this.editableEl()?.nativeElement;
if (!el) return false;
const sel = window.getSelection();
if (!sel || !sel.isCollapsed || !sel.rangeCount) return false;
if (!el.textContent) return true;
const range = sel.getRangeAt(0);
const textOffset = this._getTextOffset(el, range);
const totalLength = el.textContent.length;
if (key === 'ArrowUp' && textOffset === 0) return true;
if (key === 'ArrowDown' && textOffset === totalLength) return true;
const marker = document.createElement('span');
marker.textContent = '\u200b';
range.insertNode(marker);
const markerRect = marker.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
marker.remove();
sel.collapse(range.startContainer, range.startOffset);
if (!markerRect.height) return false;
const tolerance = 4;
if (key === 'ArrowUp') {
return markerRect.top - elRect.top < tolerance;
} else {
return elRect.bottom - markerRect.bottom < tolerance;
}
}
}

View file

@ -1,520 +0,0 @@
import { inject, Injectable, OnDestroy } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { WorkContextService } from '../work-context/work-context.service';
import { WorkContextType } from '../work-context/work-context.model';
import {
computeBlocksDelta,
DividerBlock,
DocumentBlock,
HeadingBlock,
HeadingLevel,
TaskBlock,
TextBlock,
} from './document-block.model';
import { updateProject } from '../project/store/project.actions';
import { selectProjectFeatureState } from '../project/store/project.selectors';
import { updateTag } from '../tag/store/tag.actions';
import { selectTagFeatureState } from '../tag/store/tag.reducer';
import {
updateDocumentBlocksDelta,
updateDocumentBlocksLocal,
} from './store/document-mode.actions';
import { uuidv7 } from '../../util/uuid-v7';
const SYNC_DEBOUNCE_MS = 5_000;
interface ActiveContext {
id: string;
type: WorkContextType;
title: string;
taskIds: string[];
documentBlocks?: DocumentBlock[];
isDocumentMode?: boolean;
}
@Injectable({ providedIn: 'root' })
export class DocumentModeService implements OnDestroy {
private _store = inject(Store);
private _workContextService = inject(WorkContextService);
private _activeWorkContext = toSignal(this._workContextService.activeWorkContext$);
private _projectState = this._store.selectSignal(selectProjectFeatureState);
private _tagState = this._store.selectSignal(selectTagFeatureState);
private _syncTimeout: ReturnType<typeof setTimeout> | null = null;
private _pendingSync: {
contextId: string;
contextType: WorkContextType;
} | null = null;
private _lastPersistedBlocks: DocumentBlock[] = [];
private _lastPersistedContextId: string | null = null;
private _beforeUnloadHandler = (): void => this._flushSync();
private _visibilityHandler = (): void => {
if (document.visibilityState === 'hidden') {
this._flushSync();
}
};
constructor() {
window.addEventListener('beforeunload', this._beforeUnloadHandler);
document.addEventListener('visibilitychange', this._visibilityHandler);
}
ngOnDestroy(): void {
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
document.removeEventListener('visibilitychange', this._visibilityHandler);
this._flushSync();
}
toggleDocumentMode(): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const isDocumentMode = !ctx.isDocumentMode;
let documentBlocks = ctx.documentBlocks;
if (isDocumentMode && (!documentBlocks || documentBlocks.length === 0)) {
const titleBlock: HeadingBlock = {
id: uuidv7(),
type: 'heading',
content: ctx.title || '',
level: 1,
};
// Seed document blocks from existing tasks
const taskBlocks: DocumentBlock[] = (ctx.taskIds || []).map((taskId) => ({
id: uuidv7(),
type: 'task' as const,
taskId,
}));
const dividerBlock: DividerBlock = { id: uuidv7(), type: 'divider' };
documentBlocks = [
titleBlock,
dividerBlock,
...taskBlocks,
{ id: uuidv7(), type: 'text' as const, content: '' },
];
}
// Toggle is always persisted immediately via full updateProject/updateTag
this._dispatchPersistent(id, type, { isDocumentMode, documentBlocks });
this._updateSnapshot(id, documentBlocks || []);
}
/**
* Ensure all tasks from taskIds have a corresponding TaskBlock in documentBlocks.
* Appends missing tasks at the end (before the trailing text block if one exists).
*/
syncMissingTasks(): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = ctx.documentBlocks || [];
const existingTaskIds = new Set(
blocks.filter((b) => b.type === 'task').map((b) => (b as TaskBlock).taskId),
);
const missingTaskIds = (ctx.taskIds || []).filter((tid) => !existingTaskIds.has(tid));
if (missingTaskIds.length === 0) return;
const newBlocks: DocumentBlock[] = missingTaskIds.map((taskId) => ({
id: uuidv7(),
type: 'task' as const,
taskId,
}));
// Insert before the last block if it's an empty text block, otherwise append
const updated = [...blocks];
const lastBlock = updated[updated.length - 1];
if (lastBlock?.type === 'text' && !(lastBlock as TextBlock).content) {
updated.splice(updated.length - 1, 0, ...newBlocks);
} else {
updated.push(...newBlocks);
}
this._dispatchLocal(id, type, updated);
}
/**
* Remove task blocks whose tasks no longer exist (e.g. deleted from list mode).
*/
removeOrphanedTaskBlocks(): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = ctx.documentBlocks || [];
const validTaskIds = new Set(ctx.taskIds || []);
const cleaned = blocks.filter(
(b) => b.type !== 'task' || validTaskIds.has((b as TaskBlock).taskId),
);
if (cleaned.length < blocks.length) {
this._dispatchLocal(id, type, cleaned);
}
}
addBlock(block: DocumentBlock, afterBlockId?: string): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = [...(ctx.documentBlocks || [])];
if (afterBlockId) {
const idx = blocks.findIndex((b) => b.id === afterBlockId);
if (idx === -1) {
blocks.push(block);
} else {
blocks.splice(idx + 1, 0, block);
}
} else {
blocks.push(block);
}
this._dispatchLocal(id, type, blocks);
}
removeBlock(blockId: string): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = (ctx.documentBlocks || []).filter((b) => b.id !== blockId);
this._dispatchLocal(id, type, blocks);
}
moveBlock(blockId: string, newIndex: number): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = [...(ctx.documentBlocks || [])];
const oldIndex = blocks.findIndex((b) => b.id === blockId);
if (oldIndex === -1) return;
const [block] = blocks.splice(oldIndex, 1);
blocks.splice(newIndex, 0, block);
this._dispatchLocal(id, type, blocks);
}
updateBlockContent(blockId: string, changes: Partial<TextBlock | HeadingBlock>): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = (ctx.documentBlocks || []).map((b) =>
b.id === blockId ? ({ ...b, ...changes } as DocumentBlock) : b,
);
this._dispatchLocal(id, type, blocks);
}
createTextBlock(content: string = '', afterBlockId?: string): void {
const block: TextBlock = { id: uuidv7(), type: 'text', content };
this.addBlock(block, afterBlockId);
}
createHeadingBlock(
level: HeadingLevel,
content: string = '',
afterBlockId?: string,
): void {
const block: HeadingBlock = { id: uuidv7(), type: 'heading', content, level };
this.addBlock(block, afterBlockId);
}
createTaskBlock(taskId: string, afterBlockId?: string): void {
const block: TaskBlock = { id: uuidv7(), type: 'task', taskId };
this.addBlock(block, afterBlockId);
}
createDividerBlock(afterBlockId?: string): string {
const block: DividerBlock = { id: uuidv7(), type: 'divider' };
this.addBlock(block, afterBlockId);
return block.id;
}
/**
* Split a text/heading block at the cursor position.
* The current block keeps `before`, a new text block gets `after`.
* Returns the new block's id for focusing.
*/
splitBlock(blockId: string, before: string, after: string): string {
const newBlockId = uuidv7();
const active = this._getActiveContext();
if (!active) return newBlockId;
const { id, type, ctx } = active;
const blocks = (ctx.documentBlocks || []).map((b) => {
if (b.id !== blockId) return b;
if (b.type === 'text') return { ...b, content: before } as TextBlock;
if (b.type === 'heading') return { ...b, content: before } as HeadingBlock;
return b;
});
const idx = blocks.findIndex((b) => b.id === blockId);
const sourceBlock = (ctx.documentBlocks || []).find((b) => b.id === blockId);
if (idx === -1 || !sourceBlock) return newBlockId;
// After split, new block is always text (like Super List / Notion)
const newBlock: DocumentBlock = { id: newBlockId, type: 'text', content: after };
blocks.splice(idx + 1, 0, newBlock);
this._dispatchLocal(id, type, blocks);
return newBlockId;
}
/**
* Merge a block's content into the previous block, then remove it.
* Returns the target block id and the offset where content was appended.
*/
mergeBlockIntoPrevious(blockId: string): { targetId: string; offset: number } | null {
const active = this._getActiveContext();
if (!active) return null;
const { id, type, ctx } = active;
const blocks = [...(ctx.documentBlocks || [])];
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx <= 0) return null;
const current = blocks[idx];
const prev = blocks[idx - 1];
// Only merge into text/heading blocks
if (prev.type !== 'text' && prev.type !== 'heading') return null;
const prevContent = (prev as TextBlock | HeadingBlock).content;
const currentContent =
current.type === 'text' || current.type === 'heading'
? (current as TextBlock | HeadingBlock).content
: '';
const offset = prevContent.length;
const merged = prevContent + currentContent;
const updated = blocks
.map((b) => {
if (b.id === prev.id) {
return { ...b, content: merged } as DocumentBlock;
}
return b;
})
.filter((b) => b.id !== blockId);
this._dispatchLocal(id, type, updated);
return { targetId: prev.id, offset };
}
appendTaskBlockIfMissing(taskId: string): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = ctx.documentBlocks || [];
const exists = blocks.some((b) => b.type === 'task' && b.taskId === taskId);
if (!exists) {
const newBlock: TaskBlock = { id: uuidv7(), type: 'task', taskId };
this._dispatchLocal(id, type, [...blocks, newBlock]);
}
}
/**
* Convert a block to a different type.
* text/heading task: creates a new task, replaces block with TaskBlock.
* task text: extracts title, replaces TaskBlock with TextBlock.
* text heading: preserves content.
*/
convertBlock(
blockId: string,
targetType: string,
taskService: { add: (title: string) => string },
): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = [...(ctx.documentBlocks || [])];
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const block = blocks[idx];
let content = '';
if (block.type === 'text' || block.type === 'heading') {
content = (block as TextBlock | HeadingBlock).content;
}
let newBlock: DocumentBlock;
switch (targetType) {
case 'text':
if (block.type === 'task') {
// We can't easily get the task title here synchronously,
// so just create an empty text block
newBlock = { id: block.id, type: 'text', content: '' };
} else {
newBlock = { id: block.id, type: 'text', content };
}
break;
case 'h1':
newBlock = { id: block.id, type: 'heading', content, level: 1 as HeadingLevel };
break;
case 'h2':
newBlock = { id: block.id, type: 'heading', content, level: 2 as HeadingLevel };
break;
case 'h3':
newBlock = { id: block.id, type: 'heading', content, level: 3 as HeadingLevel };
break;
case 'task': {
const taskId = taskService.add(content || 'New task');
newBlock = { id: block.id, type: 'task', taskId };
break;
}
case 'divider':
newBlock = { id: block.id, type: 'divider' };
break;
default:
return;
}
blocks[idx] = newBlock;
this._dispatchLocal(id, type, blocks);
}
/**
* Duplicate a block, inserting the copy immediately after the original.
*/
duplicateBlock(blockId: string, taskService: { add: (title: string) => string }): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = [...(ctx.documentBlocks || [])];
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const block = blocks[idx];
let newBlock: DocumentBlock;
if (block.type === 'task') {
// Create a new task for the duplicate
const newTaskId = taskService.add('New task');
newBlock = { id: uuidv7(), type: 'task', taskId: newTaskId };
} else if (block.type === 'heading') {
newBlock = {
id: uuidv7(),
type: 'heading',
content: (block as HeadingBlock).content,
level: (block as HeadingBlock).level,
};
} else if (block.type === 'divider') {
newBlock = { id: uuidv7(), type: 'divider' };
} else {
newBlock = {
id: uuidv7(),
type: 'text',
content: (block as TextBlock).content,
};
}
blocks.splice(idx + 1, 0, newBlock);
this._dispatchLocal(id, type, blocks);
}
removeTaskBlock(taskId: string): void {
const active = this._getActiveContext();
if (!active) return;
const { id, type, ctx } = active;
const blocks = (ctx.documentBlocks || []).filter(
(b) => !(b.type === 'task' && b.taskId === taskId),
);
this._dispatchLocal(id, type, blocks);
}
private _getActiveContext(): {
id: string;
type: WorkContextType;
ctx: ActiveContext;
} | null {
const ctx = this._activeWorkContext();
if (!ctx) return null;
return {
id: ctx.id,
type: ctx.type,
ctx: {
id: ctx.id,
type: ctx.type,
title: ctx.title,
taskIds: ctx.taskIds,
documentBlocks: ctx.documentBlocks,
isDocumentMode: ctx.isDocumentMode,
},
};
}
/**
* Dispatch non-persistent action for immediate local UI update,
* then schedule a debounced persistent sync.
*/
private _dispatchLocal(
contextId: string,
contextType: WorkContextType,
documentBlocks: DocumentBlock[],
): void {
this._store.dispatch(
updateDocumentBlocksLocal({
contextId,
contextType: contextType === WorkContextType.PROJECT ? 'PROJECT' : 'TAG',
documentBlocks,
}),
);
this._scheduleSyncDebounced(contextId, contextType);
}
/**
* Dispatch persistent action immediately (for toggle, etc.)
*/
private _dispatchPersistent(
contextId: string,
contextType: WorkContextType,
changes: { documentBlocks?: DocumentBlock[]; isDocumentMode?: boolean },
): void {
if (contextType === WorkContextType.PROJECT) {
this._store.dispatch(updateProject({ project: { id: contextId, changes } }));
} else {
this._store.dispatch(
updateTag({ tag: { id: contextId, changes }, isSkipSnack: true }),
);
}
}
private _scheduleSyncDebounced(contextId: string, contextType: WorkContextType): void {
// C1: flush pending sync if it's for a different context
if (this._pendingSync && this._pendingSync.contextId !== contextId) {
this._flushSync();
}
this._pendingSync = { contextId, contextType };
if (this._syncTimeout) {
clearTimeout(this._syncTimeout);
}
this._syncTimeout = setTimeout(() => this._flushSync(), SYNC_DEBOUNCE_MS);
}
private _flushSync(): void {
if (!this._pendingSync) return;
const { contextId, contextType } = this._pendingSync;
this._pendingSync = null;
if (this._syncTimeout) {
clearTimeout(this._syncTimeout);
this._syncTimeout = null;
}
// Read blocks by entity ID so flush works even after context switch
const entity =
contextType === WorkContextType.PROJECT
? this._projectState().entities[contextId]
: this._tagState().entities[contextId];
if (!entity) return;
const currentBlocks = entity.documentBlocks || [];
const lastBlocks =
this._lastPersistedContextId === contextId ? this._lastPersistedBlocks : [];
const delta = computeBlocksDelta(lastBlocks, currentBlocks);
if (!delta) return; // Nothing changed since last persist
this._store.dispatch(
updateDocumentBlocksDelta({
contextId,
contextType: contextType === WorkContextType.PROJECT ? 'PROJECT' : 'TAG',
delta,
}),
);
this._updateSnapshot(contextId, currentBlocks);
}
private _updateSnapshot(contextId: string, blocks: DocumentBlock[]): void {
this._lastPersistedContextId = contextId;
this._lastPersistedBlocks = blocks;
}
}

View file

@ -1,483 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
ElementRef,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { TaskBlock } from '../document-block.model';
import { Store } from '@ngrx/store';
import { selectTaskById } from '../../tasks/store/task.selectors';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';
import { TaskService } from '../../tasks/task.service';
import { Task } from '../../tasks/task.model';
import { MatIcon } from '@angular/material/icon';
import { TagListComponent } from '../../tag/tag-list/tag-list.component';
@Component({
selector: 'document-task-block',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon, TagListComponent],
template: `
@if (task(); as t) {
<div
class="task-row"
[class.is-done]="t.isDone"
>
<div
class="done-toggle"
[class.is-done]="t.isDone"
(click)="toggleDone(t)"
role="checkbox"
[attr.aria-checked]="t.isDone"
tabindex="0"
(keydown.enter)="toggleDone(t); $event.stopPropagation()"
(keydown.space)="
toggleDone(t); $event.stopPropagation(); $event.preventDefault()
"
>
<svg
class="done-toggle-svg"
viewBox="0 0 24 24"
fill="none"
>
<circle
class="done-circle"
cx="12"
cy="12"
r="10"
/>
<polyline
class="done-check"
points="8,12 11,15 16,9"
/>
</svg>
</div>
<span
#titleEl
class="task-title"
contenteditable="true"
(input)="onTitleInput($event, t)"
(blur)="onTitleBlur($event, t)"
(keydown)="onKeydown($event)"
></span>
<button
class="detail-btn"
(click)="openDetail(t)"
>
<mat-icon>arrow_forward</mat-icon>
</button>
</div>
@if (t.tagIds?.length || t.projectId || t.repeatCfgId || t.issueId) {
<div class="tags-row">
<tag-list [task]="t"></tag-list>
</div>
}
}
`,
styles: [
`
:host {
display: block;
}
.task-row {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
}
@keyframes draw-check {
from {
stroke-dashoffset: 16;
}
to {
stroke-dashoffset: 0;
}
}
.done-toggle {
width: 24px;
height: 24px;
min-width: 24px;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
padding: 0;
}
.done-toggle-svg {
width: 24px;
height: 24px;
opacity: 0.3;
transition: opacity var(--transition-duration-m) var(--ani-standard-timing);
}
.done-circle {
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.done-check {
stroke: currentColor;
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16;
stroke-dashoffset: 16;
}
.done-toggle.is-done .done-toggle-svg {
opacity: 0.5;
}
.done-toggle.is-done .done-check {
animation: draw-check 150ms ease-out forwards;
}
.done-toggle:hover .done-toggle-svg {
opacity: 0.6;
}
.done-toggle.is-done:hover .done-toggle-svg {
opacity: 0.8;
}
.task-title {
flex: 1;
outline: none;
cursor: text;
line-height: 1.5;
color: inherit;
min-height: 1.5em;
}
.task-title:empty:focus::before {
content: 'To-do';
color: var(--text-color-muted);
opacity: 0.4;
pointer-events: none;
}
.is-done .task-title {
text-decoration: line-through;
color: var(--text-color-muted);
}
.detail-btn {
opacity: 0;
border: none;
background: none;
cursor: pointer;
color: var(--text-color-muted);
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity var(--transition-duration-s) var(--ani-standard-timing);
}
.task-row:hover .detail-btn {
opacity: 1;
}
.detail-btn:hover {
color: var(--text-color);
background: var(--c-dark-10);
}
:host-context(.isDarkTheme) .detail-btn:hover {
background: var(--c-light-05);
}
.detail-btn .mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.tags-row {
padding-left: 34px;
margin-top: -2px;
opacity: 0.55;
filter: grayscale(1);
transition:
opacity var(--transition-duration-s) var(--ani-standard-timing),
filter var(--transition-duration-s) var(--ani-standard-timing);
}
:host(:focus-within) .tags-row {
opacity: 1;
filter: none;
}
`,
],
})
export class DocumentTaskBlockComponent {
block = input.required<TaskBlock>();
enterPressed = output<void>();
enterOnEmpty = output<void>();
splitAtCursor = output<{ before: string; after: string }>();
backspaceOnEmpty = output<void>();
backspaceAtStart = output<string>();
navigateUp = output<number>();
navigateDown = output<number>();
private _store = inject(Store);
private _taskService = inject(TaskService);
private _destroyRef = inject(DestroyRef);
private _lastSetTitle = '';
private _titleSaveTimeout: ReturnType<typeof setTimeout> | null = null;
private _pendingTitle: { taskId: string; title: string } | null = null;
titleEl = viewChild<ElementRef<HTMLSpanElement>>('titleEl');
task = toSignal(
toObservable(this.block).pipe(
switchMap((b) => this._store.select(selectTaskById, { id: b.taskId })),
),
);
constructor() {
effect(() => {
const t = this.task();
const el = this.titleEl()?.nativeElement;
if (t && el && t.title !== this._lastSetTitle) {
if (el.textContent !== t.title) {
el.textContent = t.title;
}
this._lastSetTitle = t.title;
}
});
this._destroyRef.onDestroy(() => {
if (this._titleSaveTimeout) {
clearTimeout(this._titleSaveTimeout);
this._flushTitleSave();
}
});
}
focus(position?: 'start' | 'end'): void {
const el = this.titleEl()?.nativeElement;
if (!el) return;
el.focus();
if (position && el.childNodes.length > 0) {
const sel = window.getSelection();
if (!sel) return;
const textNode = el.firstChild!;
if (position === 'end') {
sel.collapse(textNode, textNode.textContent?.length || 0);
} else {
sel.collapse(textNode, 0);
}
}
}
focusAtOffset(offset: number): void {
const el = this.titleEl()?.nativeElement;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel) return;
const pos = this._findNodeAtOffset(el, offset);
if (pos) {
sel.collapse(pos.node, pos.offset);
}
}
toggleDone(task: Task): void {
this._taskService.update(task.id, { isDone: !task.isDone });
}
openDetail(task: Task): void {
this._taskService.setSelectedId(task.id);
}
onTitleInput(_ev: Event, task: Task): void {
const el = this.titleEl()?.nativeElement;
if (!el) return;
const newTitle = el.textContent?.trim() || '';
if (newTitle === task.title) return;
if (this._titleSaveTimeout) clearTimeout(this._titleSaveTimeout);
this._pendingTitle = { taskId: task.id, title: newTitle };
this._titleSaveTimeout = setTimeout(() => this._flushTitleSave(), 1000);
}
onTitleBlur(ev: FocusEvent, task: Task): void {
if (this._titleSaveTimeout) {
clearTimeout(this._titleSaveTimeout);
this._titleSaveTimeout = null;
}
const newTitle = (ev.target as HTMLElement).textContent?.trim();
if (newTitle && newTitle !== task.title) {
this._lastSetTitle = newTitle;
this._taskService.update(task.id, { title: newTitle });
}
this._pendingTitle = null;
}
onKeydown(ev: KeyboardEvent): void {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
const el = this.titleEl()?.nativeElement;
if (!el) return;
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const fullText = el.textContent || '';
const offset = this._getTextOffset(el, range);
const before = fullText.substring(0, offset);
const after = fullText.substring(offset);
if (before || after) {
this.splitAtCursor.emit({ before, after });
} else {
this.enterOnEmpty.emit();
}
} else {
this.enterOnEmpty.emit();
}
return;
}
if (ev.key === 'Backspace' || ev.key === 'Delete') {
const el = this.titleEl()?.nativeElement;
if (!el) return;
const text = el.textContent || '';
if (!text) {
ev.preventDefault();
this.backspaceOnEmpty.emit();
return;
}
if (ev.key === 'Backspace') {
const sel = window.getSelection();
if (sel && sel.isCollapsed && this._isAtStart(el, sel)) {
ev.preventDefault();
this.backspaceAtStart.emit(text);
return;
}
}
}
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
if (this._shouldNavigateAcrossBlocks(ev.key)) {
ev.preventDefault();
const offset = this._getCurrentOffset();
if (ev.key === 'ArrowUp') {
this.navigateUp.emit(offset);
} else {
this.navigateDown.emit(offset);
}
}
}
}
private _getCurrentOffset(): number {
const el = this.titleEl()?.nativeElement;
if (!el) return 0;
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return 0;
return this._getTextOffset(el, sel.getRangeAt(0));
}
private _findNodeAtOffset(
container: Node,
target: number,
): { node: Node; offset: number } | null {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let remaining = target;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const len = node.textContent?.length || 0;
if (remaining <= len) {
return { node, offset: remaining };
}
remaining -= len;
}
const lastNode = this._lastTextNode(container);
if (lastNode) {
return { node: lastNode, offset: lastNode.textContent?.length || 0 };
}
return null;
}
private _lastTextNode(container: Node): Text | null {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let last: Text | null = null;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
last = node;
}
return last;
}
private _getTextOffset(container: HTMLElement, range: Range): number {
const preRange = document.createRange();
preRange.selectNodeContents(container);
preRange.setEnd(range.startContainer, range.startOffset);
return preRange.toString().length;
}
private _isAtStart(el: HTMLElement, sel: Selection): boolean {
if (!sel.rangeCount) return false;
const range = sel.getRangeAt(0);
return this._getTextOffset(el, range) === 0;
}
private _flushTitleSave(): void {
if (this._pendingTitle) {
this._lastSetTitle = this._pendingTitle.title;
this._taskService.update(this._pendingTitle.taskId, {
title: this._pendingTitle.title,
});
this._pendingTitle = null;
}
this._titleSaveTimeout = null;
}
private _shouldNavigateAcrossBlocks(key: 'ArrowUp' | 'ArrowDown'): boolean {
const el = this.titleEl()?.nativeElement;
if (!el) return false;
const sel = window.getSelection();
if (!sel || !sel.isCollapsed || !sel.rangeCount) return false;
if (!el.textContent) return true;
const range = sel.getRangeAt(0);
const textOffset = this._getTextOffset(el, range);
const totalLength = el.textContent.length;
if (key === 'ArrowUp' && textOffset === 0) return true;
if (key === 'ArrowDown' && textOffset === totalLength) return true;
const marker = document.createElement('span');
marker.textContent = '\u200b';
range.insertNode(marker);
const markerRect = marker.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
marker.remove();
sel.collapse(range.startContainer, range.startOffset);
if (!markerRect.height) return false;
const tolerance = 4;
if (key === 'ArrowUp') {
return markerRect.top - elRect.top < tolerance;
} else {
return elRect.bottom - markerRect.bottom < tolerance;
}
}
}

View file

@ -1,361 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { TextBlock } from '../document-block.model';
import { sanitizeBlockHtml } from '../sanitize-block-html';
@Component({
selector: 'document-text-block',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [],
template: `
<div
#editableEl
class="text-block"
contenteditable="true"
data-placeholder="Type something, or press / for commands"
(input)="onInput($event)"
(keydown)="onKeydown($event)"
(paste)="onPaste($event)"
></div>
`,
styles: [
`
:host {
display: block;
}
.text-block {
outline: none;
cursor: text;
padding: 3px 0;
line-height: 1.6;
color: inherit;
word-wrap: break-word;
white-space: pre-wrap;
min-height: 1.6em;
}
.text-block:empty:focus::before {
content: attr(data-placeholder);
color: var(--text-color-muted);
opacity: 0.4;
pointer-events: none;
}
`,
],
})
export class DocumentTextBlockComponent {
block = input.required<TextBlock>();
contentChanged = output<string>();
enterPressed = output<void>();
splitAtCursor = output<{ before: string; after: string }>();
backspaceOnEmpty = output<void>();
backspaceAtStart = output<string>();
markdownConvert = output<{ targetType: string; content: string }>();
slashTyped = output<HTMLElement>();
slashFilterChanged = output<string>();
navigateUp = output<number>();
navigateDown = output<number>();
editableEl = viewChild<ElementRef<HTMLDivElement>>('editableEl');
private _initialized = signal(false);
private _slashActive = false;
constructor() {
effect(() => {
const el = this.editableEl()?.nativeElement;
const content = this.block().content;
if (el) {
// Skip if this is a local edit (user is actively typing)
if (this._initialized() && document.activeElement === el) return;
if (content.includes('<')) {
el.innerHTML = sanitizeBlockHtml(content);
} else {
el.textContent = content;
}
this._initialized.set(true);
}
});
}
focus(position?: 'start' | 'end'): void {
const el = this.editableEl()?.nativeElement;
if (!el) return;
el.focus();
if (position && el.childNodes.length > 0) {
const sel = window.getSelection();
if (!sel) return;
const textNode = el.firstChild!;
if (position === 'end') {
sel.collapse(textNode, textNode.textContent?.length || 0);
} else {
sel.collapse(textNode, 0);
}
}
}
focusAtOffset(offset: number): void {
const el = this.editableEl()?.nativeElement;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel) return;
const pos = this._findNodeAtOffset(el, offset);
if (pos) {
sel.collapse(pos.node, pos.offset);
}
}
onInput(ev: Event): void {
const el = ev.target as HTMLElement;
const text = el.textContent || '';
// Emit innerHTML if there are formatting elements, otherwise plain text
const hasFormatting = el.querySelector('b, strong, i, em, a, u');
this.contentChanged.emit(hasFormatting ? sanitizeBlockHtml(el.innerHTML) : text);
// Detect markdown shortcuts at line start
const md = this._detectMarkdownShortcut(text);
if (md) {
this.markdownConvert.emit(md);
return;
}
if (this._slashActive) {
const slashIdx = text.lastIndexOf('/');
if (slashIdx >= 0) {
const filter = text.substring(slashIdx + 1);
if (filter.includes(' ')) {
this._slashActive = false;
} else {
this.slashFilterChanged.emit(filter);
}
} else {
this._slashActive = false;
}
} else if (text.endsWith('/')) {
this._slashActive = true;
this.slashTyped.emit(el);
}
}
onPaste(ev: ClipboardEvent): void {
ev.preventDefault();
const text = ev.clipboardData?.getData('text/plain') || '';
document.execCommand('insertText', false, text);
}
cancelSlash(): void {
this._slashActive = false;
}
/**
* Remove the slash (and any filter text after it) from the DOM element directly.
* Needed because the contenteditable element is the DOM source of truth after init.
*/
clearSlashText(): void {
this._slashActive = false;
const el = this.editableEl()?.nativeElement;
if (!el) return;
const text = el.textContent || '';
const slashIdx = text.lastIndexOf('/');
if (slashIdx >= 0) {
const cleaned = text.substring(0, slashIdx);
el.textContent = cleaned;
this.contentChanged.emit(cleaned);
}
}
onKeydown(ev: KeyboardEvent): void {
// Inline formatting shortcuts
if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey) {
if (ev.key === 'b') {
ev.preventDefault();
document.execCommand('bold');
this._emitContent();
return;
}
if (ev.key === 'i') {
ev.preventDefault();
document.execCommand('italic');
this._emitContent();
return;
}
}
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
const el = this.editableEl()?.nativeElement;
if (!el) return;
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const fullText = el.textContent || '';
const offset = this._getTextOffset(el, range);
const before = fullText.substring(0, offset);
const after = fullText.substring(offset);
this.splitAtCursor.emit({ before, after });
} else {
this.enterPressed.emit();
}
return;
}
if (ev.key === 'Backspace' || ev.key === 'Delete') {
const el = ev.target as HTMLElement;
const text = el.textContent || '';
if (!text) {
ev.preventDefault();
this.backspaceOnEmpty.emit();
return;
}
if (ev.key === 'Backspace') {
const sel = window.getSelection();
if (sel && sel.isCollapsed && this._isAtStart(el, sel)) {
ev.preventDefault();
this.backspaceAtStart.emit(text);
return;
}
}
}
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
if (this._shouldNavigateAcrossBlocks(ev.key)) {
ev.preventDefault();
const offset = this._getCurrentOffset();
if (ev.key === 'ArrowUp') {
this.navigateUp.emit(offset);
} else {
this.navigateDown.emit(offset);
}
}
}
}
private _emitContent(): void {
const el = this.editableEl()?.nativeElement;
if (!el) return;
const hasFormatting = el.querySelector('b, strong, i, em, a, u');
this.contentChanged.emit(
hasFormatting ? sanitizeBlockHtml(el.innerHTML) : el.textContent || '',
);
}
private _detectMarkdownShortcut(
text: string,
): { targetType: string; content: string } | null {
if (text.startsWith('### ')) {
return { targetType: 'h3', content: text.substring(4) };
}
if (text.startsWith('## ')) {
return { targetType: 'h2', content: text.substring(3) };
}
if (text.startsWith('# ')) {
return { targetType: 'h1', content: text.substring(2) };
}
if (text.startsWith('[] ') || text.startsWith('- ')) {
return { targetType: 'task', content: text.substring(text.indexOf(' ') + 1) };
}
if (text === '---') {
return { targetType: 'divider', content: '' };
}
return null;
}
private _getCurrentOffset(): number {
const el = this.editableEl()?.nativeElement;
if (!el) return 0;
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return 0;
return this._getTextOffset(el, sel.getRangeAt(0));
}
private _findNodeAtOffset(
container: Node,
target: number,
): { node: Node; offset: number } | null {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let remaining = target;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const len = node.textContent?.length || 0;
if (remaining <= len) {
return { node, offset: remaining };
}
remaining -= len;
}
// Offset exceeds content — place at end of last text node
const lastNode = this._lastTextNode(container);
if (lastNode) {
return { node: lastNode, offset: lastNode.textContent?.length || 0 };
}
return null;
}
private _lastTextNode(container: Node): Text | null {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let last: Text | null = null;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
last = node;
}
return last;
}
private _getTextOffset(container: HTMLElement, range: Range): number {
const preRange = document.createRange();
preRange.selectNodeContents(container);
preRange.setEnd(range.startContainer, range.startOffset);
return preRange.toString().length;
}
private _isAtStart(el: HTMLElement, sel: Selection): boolean {
if (!sel.rangeCount) return false;
const range = sel.getRangeAt(0);
return this._getTextOffset(el, range) === 0;
}
private _shouldNavigateAcrossBlocks(key: 'ArrowUp' | 'ArrowDown'): boolean {
const el = this.editableEl()?.nativeElement;
if (!el) return false;
const sel = window.getSelection();
if (!sel || !sel.isCollapsed || !sel.rangeCount) return false;
if (!el.textContent) return true;
const range = sel.getRangeAt(0);
const textOffset = this._getTextOffset(el, range);
const totalLength = el.textContent.length;
// At absolute start/end → always navigate
if (key === 'ArrowUp' && textOffset === 0) return true;
if (key === 'ArrowDown' && textOffset === totalLength) return true;
// For multi-line content, use rect-based detection with a temp marker
// (collapsed range rects are often zero-height and unreliable)
const marker = document.createElement('span');
marker.textContent = '\u200b';
range.insertNode(marker);
const markerRect = marker.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
marker.remove();
// Restore selection after DOM mutation
sel.collapse(range.startContainer, range.startOffset);
if (!markerRect.height) return false;
const tolerance = 4;
if (key === 'ArrowUp') {
return markerRect.top - elRect.top < tolerance;
} else {
return elRect.bottom - markerRect.bottom < tolerance;
}
}
}

View file

@ -1,36 +0,0 @@
const ALLOWED_TAGS = new Set(['B', 'STRONG', 'I', 'EM', 'U', 'A', 'BR']);
/**
* Sanitize HTML content from document blocks.
* Only allows formatting tags produced by execCommand (bold/italic/underline/link).
* Strips all other elements (keeping their text content) and all attributes
* except href on anchor tags.
*/
export const sanitizeBlockHtml = (html: string): string => {
const template = document.createElement('template');
template.innerHTML = html;
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT);
const toUnwrap: Element[] = [];
let node: Element | null;
while ((node = walker.nextNode() as Element | null)) {
if (!ALLOWED_TAGS.has(node.tagName)) {
toUnwrap.push(node);
} else {
for (const attr of Array.from(node.attributes)) {
if (!(node.tagName === 'A' && attr.name === 'href')) {
node.removeAttribute(attr.name);
}
}
if (node.tagName === 'A') {
const href = node.getAttribute('href') || '';
if (!href.startsWith('http://') && !href.startsWith('https://')) {
node.removeAttribute('href');
}
}
}
}
for (const el of toUnwrap) {
el.replaceWith(...Array.from(el.childNodes));
}
return template.innerHTML;
};

View file

@ -1,39 +0,0 @@
import { createAction, props } from '@ngrx/store';
import { DocumentBlock, DocumentBlocksDelta } from '../document-block.model';
import { PersistentActionMeta } from '../../../op-log/core/persistent-action.interface';
import { OpType } from '../../../op-log/core/operation.types';
/**
* Non-persistent action for immediate local state updates.
* Updates documentBlocks on project/tag without creating a sync operation.
* The persistent sync is debounced separately (30s) via DocumentModeService.
*/
export const updateDocumentBlocksLocal = createAction(
'[DocumentMode] Update Document Blocks Local',
props<{
contextId: string;
contextType: 'PROJECT' | 'TAG';
documentBlocks: DocumentBlock[];
}>(),
);
/**
* Persistent action that sends only the delta (changed/removed blocks + order).
* Dispatched after debounce to create a sync operation with minimal payload.
*/
export const updateDocumentBlocksDelta = createAction(
'[DocumentMode] Update Document Blocks Delta',
(payload: {
contextId: string;
contextType: 'PROJECT' | 'TAG';
delta: DocumentBlocksDelta;
}) => ({
...payload,
meta: {
isPersistent: true,
entityType: payload.contextType,
entityId: payload.contextId,
opType: OpType.Update,
} satisfies PersistentActionMeta,
}),
);

View file

@ -23,7 +23,6 @@ const BASE_FEATURES: AppFeaturesConfig = {
isEnableUserProfiles: false,
isHabitsEnabled: false,
isFinishDayEnabled: false,
isDocumentModeEnabled: true,
};
export const ONBOARDING_PRESETS: OnboardingPreset[] = [

View file

@ -103,12 +103,6 @@ export class ProjectEffects {
() =>
this._actions$.pipe(
ofType(updateProject.type),
filter(({ project }: { project: { changes?: Record<string, unknown> } }) => {
if (!project.changes) return true;
const keys = Object.keys(project.changes);
const docKeys = new Set(['documentBlocks', 'isDocumentMode']);
return keys.some((k) => !docKeys.has(k));
}),
tap(() => {
this._snackService.open({
type: 'SUCCESS',

View file

@ -13,11 +13,6 @@ import {
moveTaskUpInTodayList,
} from '../../work-context/store/work-context-meta.actions';
import { moveItemAfterAnchor } from '../../work-context/store/work-context-meta.helper';
import {
updateDocumentBlocksDelta,
updateDocumentBlocksLocal,
} from '../../document-mode/store/document-mode.actions';
import { applyDocumentBlocksDelta } from '../../document-mode/document-block.model';
import {
arrayMoveLeftUntil,
arrayMoveRightUntil,
@ -153,25 +148,6 @@ export const projectReducer = createReducer<ProjectState>(
on(updateProject, (state, { project }) => projectAdapter.updateOne(project, state)),
on(updateDocumentBlocksLocal, (state, { contextId, contextType, documentBlocks }) => {
if (contextType !== 'PROJECT') return state;
return projectAdapter.updateOne(
{ id: contextId, changes: { documentBlocks } },
state,
);
}),
on(updateDocumentBlocksDelta, (state, { contextId, contextType, delta }) => {
if (contextType !== 'PROJECT') return state;
const entity = state.entities[contextId];
if (!entity) return state;
const documentBlocks = applyDocumentBlocksDelta(entity.documentBlocks || [], delta);
return projectAdapter.updateOne(
{ id: contextId, changes: { documentBlocks } },
state,
);
}),
// on(deleteProjects, (state, { ids }) => projectAdapter.removeMany(ids, state)),
on(loadProjects, (state, { projects }) => projectAdapter.setAll(projects, state)),

View file

@ -40,11 +40,6 @@ import {
updateTagOrder,
} from './tag.actions';
import { Log } from '../../../core/log';
import {
updateDocumentBlocksDelta,
updateDocumentBlocksLocal,
} from '../../document-mode/store/document-mode.actions';
import { applyDocumentBlocksDelta } from '../../document-mode/document-block.model';
export const TAG_FEATURE_NAME = 'tag';
const WORK_CONTEXT_TYPE: WorkContextType = WorkContextType.TAG;
@ -352,22 +347,6 @@ export const tagReducer = createReducer<TagState>(
on(updateTag, (state: TagState, { tag }) => tagAdapter.updateOne(tag, state)),
on(
updateDocumentBlocksLocal,
(state: TagState, { contextId, contextType, documentBlocks }) => {
if (contextType !== 'TAG') return state;
return tagAdapter.updateOne({ id: contextId, changes: { documentBlocks } }, state);
},
),
on(updateDocumentBlocksDelta, (state: TagState, { contextId, contextType, delta }) => {
if (contextType !== 'TAG') return state;
const entity = state.entities[contextId];
if (!entity) return state;
const documentBlocks = applyDocumentBlocksDelta(entity.documentBlocks || [], delta);
return tagAdapter.updateOne({ id: contextId, changes: { documentBlocks } }, state);
}),
on(deleteTag, (state: TagState, { id }) => tagAdapter.removeOne(id, state)),
on(deleteTags, (state: TagState, { ids }) => tagAdapter.removeMany(ids, state)),

View file

@ -46,8 +46,6 @@ export const WORK_CONTEXT_DEFAULT_COMMON: WorkContextCommon = {
icon: null,
id: '',
title: '',
documentBlocks: [],
isDocumentMode: false,
};
export const HUES = [

View file

@ -1,5 +1,4 @@
import { WorklogExportSettings } from '../worklog/worklog.model';
import { DocumentBlock } from '../document-mode/document-block.model';
// normally imported from here, but this includes non type files as well..
// import {HueValue} from 'angular-material-css-vars';
@ -70,8 +69,6 @@ export interface WorkContextCommon {
taskIds: string[];
id: string;
title: string;
documentBlocks?: DocumentBlock[];
isDocumentMode?: boolean;
}
export type WorkContextAdvancedCfgKey = keyof WorkContextAdvancedCfg;

View file

@ -10,7 +10,7 @@
class="today"
cdkScrollable
>
@if (!(isDocumentMode() && isProjectContext()) && !pluginEmbedId()) {
@if (!pluginEmbedId()) {
<header class="work-view-header">
@if (estimateRemainingToday() || workingToday()) {
<div
@ -84,8 +84,6 @@
[showFullUI]="false"
[skipCleanupOnDestroy]="true"
></plugin-index>
} @else if (isDocumentMode() && isProjectContext()) {
<document-view></document-view>
} @else {
<div class="task-list-wrapper">
@if (!(workContextService.isHasTasksToWorkOn$ | async)) {
@ -382,9 +380,7 @@
}
</div>
@if (
isShowBacklog() && !(isDocumentMode() && isProjectContext()) && !pluginEmbedId()
) {
@if (isShowBacklog() && !pluginEmbedId()) {
<div
#splitBottomEl
[style.user-focus]="splitInputPos === 100 ? 'none' : ''"

View file

@ -85,7 +85,6 @@ import { RepeatCfgPreviewComponent } from '../task-repeat-cfg/repeat-cfg-preview
import { recordSearchNavDebug } from '../../util/search-nav-debug';
import { dragDelayForTouch } from '../../util/input-intent';
import { DateService } from '../../core/date/date.service';
import { DocumentViewComponent } from '../document-mode/document-view/document-view.component';
import { PluginIndexComponent } from '../../plugins/ui/plugin-index/plugin-index.component';
import { PluginBridgeService } from '../../plugins/plugin-bridge.service';
@ -121,7 +120,6 @@ import { PluginBridgeService } from '../../plugins/plugin-bridge.service';
FinishDayBtnComponent,
ScheduledDateGroupPipe,
RepeatCfgPreviewComponent,
DocumentViewComponent,
PluginIndexComponent,
],
})
@ -147,16 +145,6 @@ export class WorkViewComponent implements OnInit, OnDestroy {
private _pluginBridge = inject(PluginBridgeService);
protected readonly dragDelayForTouch = dragDelayForTouch;
private _isDocumentModeForContext = toSignal(
this.workContextService.activeWorkContext$.pipe(map((ctx) => !!ctx.isDocumentMode)),
{ initialValue: false },
);
isDocumentMode = computed(
() =>
this._isDocumentModeForContext() &&
this._globalConfigService.appFeatures().isDocumentModeEnabled,
);
isProjectContext = toSignal(this.workContextService.isActiveWorkContextProject$, {
initialValue: false,
});

View file

@ -104,7 +104,8 @@ export enum ActionType {
NOTE_UPDATE_ORDER = '[Note] Update Note Order',
NOTE_MOVE_TO_PROJECT = '[Note] Move to other project',
// DocumentMode actions
// DocumentMode actions — feature removed, enum entry kept so historical
// op-log entries decode to a stable action type identifier.
DOCUMENT_MODE_UPDATE_BLOCKS_DELTA = '[DocumentMode] Update Document Blocks Delta',
// Project actions (P)

View file

@ -2144,7 +2144,6 @@ const T = {
GCF: {
APP_FEATURES: {
BOARDS: 'GCF.APP_FEATURES.BOARDS',
DOCUMENT_MODE: 'GCF.APP_FEATURES.DOCUMENT_MODE',
DONATE_PAGE: 'GCF.APP_FEATURES.DONATE_PAGE',
FINISH_DAY: 'GCF.APP_FEATURES.FINISH_DAY',
FOCUS_MODE: 'GCF.APP_FEATURES.FOCUS_MODE',

View file

@ -2110,7 +2110,6 @@
"SYNC_BUTTON": "Sync button",
"TIME_TRACKING": "Stopwatch time tracking",
"TITLE": "App Features",
"DOCUMENT_MODE": "Document mode",
"USER_PROFILES": "User profiles (experimental — might be removed)",
"USER_PROFILES_HINT": "Allows you to create and switch between different user profiles, each with separate settings, tasks, and sync configurations. The profile management button will appear in the top-right corner when enabled. Note: Disabling this feature will hide the UI but preserve your profile data. (Experimental feature. No guarantees. Make sure to have a backup!)",
"USER_PROFILES_WARNING": "<strong>Experimental feature:</strong> This feature is still in development and may be removed or significantly changed in a future update. Use at your own risk and make sure to keep backups of your data.",