diff --git a/src/app/core-ui/main-header/page-title/page-title.component.ts b/src/app/core-ui/main-header/page-title/page-title.component.ts index 045cfc2ed0..2f5b724e8a 100644 --- a/src/app/core-ui/main-header/page-title/page-title.component.ts +++ b/src/app/core-ui/main-header/page-title/page-title.component.ts @@ -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 more_vert @if (isWorkViewPage()) { - @if (!isDocumentMode()) { - + - - } - @if (isProjectContext() && isDocumentModeEnabled()) { - - } + } } @@ -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( diff --git a/src/app/core/persistence/operation-log/compact/action-type-codes.ts b/src/app/core/persistence/operation-log/compact/action-type-codes.ts index f480ec18f9..2535fdd648 100644 --- a/src/app/core/persistence/operation-log/compact/action-type-codes.ts +++ b/src/app/core/persistence/operation-log/compact/action-type-codes.ts @@ -50,7 +50,8 @@ export const ACTION_TYPE_TO_CODE: Record = { // 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) diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index 5c6aca6179..27a0f602ee 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -28,7 +28,6 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { isEnableUserProfiles: false, isHabitsEnabled: true, isFinishDayEnabled: true, - isDocumentModeEnabled: true, }, localization: { lng: undefined, diff --git a/src/app/features/config/form-cfgs/app-features-form.const.ts b/src/app/features/config/form-cfgs/app-features-form.const.ts index d1a5a73247..139277605a 100644 --- a/src/app/features/config/form-cfgs/app-features-form.const.ts +++ b/src/app/features/config/form-cfgs/app-features-form.const.ts @@ -114,14 +114,6 @@ export const APP_FEATURES_FORM_CFG: ConfigFormSection = { icon: 'heart_check', }, }, - { - key: 'isDocumentModeEnabled', - type: 'slide-toggle', - templateOptions: { - label: T.GCF.APP_FEATURES.DOCUMENT_MODE, - icon: 'article', - }, - }, { key: 'isEnableUserProfiles', type: 'slide-toggle', diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index 9d39c8c704..f29ba6a7c0 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -21,7 +21,6 @@ export type AppFeaturesConfig = Readonly<{ isEnableUserProfiles: boolean; isHabitsEnabled: boolean; isFinishDayEnabled: boolean; - isDocumentModeEnabled: boolean; }>; export type MiscConfig = Readonly<{ diff --git a/src/app/features/document-mode/document-block.model.ts b/src/app/features/document-mode/document-block.model.ts deleted file mode 100644 index 9a38614b02..0000000000 --- a/src/app/features/document-mode/document-block.model.ts +++ /dev/null @@ -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(); - 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; -}; diff --git a/src/app/features/document-mode/document-divider-block/document-divider-block.component.ts b/src/app/features/document-mode/document-divider-block/document-divider-block.component.ts deleted file mode 100644 index c214b5fae9..0000000000 --- a/src/app/features/document-mode/document-divider-block/document-divider-block.component.ts +++ /dev/null @@ -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: ` -
- `, - 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(); - enterPressed = output(); - deleteBlock = output(); - navigateUp = output(); - navigateDown = output(); - - dividerEl = viewChild>('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(); - } - } -} diff --git a/src/app/features/document-mode/document-heading-block/document-heading-block.component.ts b/src/app/features/document-mode/document-heading-block/document-heading-block.component.ts deleted file mode 100644 index 5aeecfa709..0000000000 --- a/src/app/features/document-mode/document-heading-block/document-heading-block.component.ts +++ /dev/null @@ -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) { -

- } - @case (2) { -

- } - @case (3) { -

- } - } - `, - 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(); - contentChanged = output(); - enterPressed = output(); - splitAtCursor = output<{ before: string; after: string }>(); - backspaceOnEmpty = output(); - backspaceAtStart = output(); - navigateUp = output(); - navigateDown = output(); - - editableEl = viewChild>('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; - } - } -} diff --git a/src/app/features/document-mode/document-mode.service.ts b/src/app/features/document-mode/document-mode.service.ts deleted file mode 100644 index 14f5c26a8d..0000000000 --- a/src/app/features/document-mode/document-mode.service.ts +++ /dev/null @@ -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 | 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): 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; - } -} diff --git a/src/app/features/document-mode/document-task-block/document-task-block.component.ts b/src/app/features/document-mode/document-task-block/document-task-block.component.ts deleted file mode 100644 index c5cb5eae15..0000000000 --- a/src/app/features/document-mode/document-task-block/document-task-block.component.ts +++ /dev/null @@ -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) { -
- - - -
- @if (t.tagIds?.length || t.projectId || t.repeatCfgId || t.issueId) { -
- -
- } - } - `, - 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(); - enterPressed = output(); - enterOnEmpty = output(); - splitAtCursor = output<{ before: string; after: string }>(); - backspaceOnEmpty = output(); - backspaceAtStart = output(); - navigateUp = output(); - navigateDown = output(); - - private _store = inject(Store); - private _taskService = inject(TaskService); - private _destroyRef = inject(DestroyRef); - private _lastSetTitle = ''; - private _titleSaveTimeout: ReturnType | null = null; - private _pendingTitle: { taskId: string; title: string } | null = null; - - titleEl = viewChild>('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; - } - } -} diff --git a/src/app/features/document-mode/document-text-block/document-text-block.component.ts b/src/app/features/document-mode/document-text-block/document-text-block.component.ts deleted file mode 100644 index fafccc468b..0000000000 --- a/src/app/features/document-mode/document-text-block/document-text-block.component.ts +++ /dev/null @@ -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: ` -
- `, - 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(); - contentChanged = output(); - enterPressed = output(); - splitAtCursor = output<{ before: string; after: string }>(); - backspaceOnEmpty = output(); - backspaceAtStart = output(); - markdownConvert = output<{ targetType: string; content: string }>(); - slashTyped = output(); - slashFilterChanged = output(); - navigateUp = output(); - navigateDown = output(); - - editableEl = viewChild>('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; - } - } -} diff --git a/src/app/features/document-mode/document-view/document-view.component.ts b/src/app/features/document-mode/document-view/document-view.component.ts deleted file mode 100644 index f62393467f..0000000000 --- a/src/app/features/document-mode/document-view/document-view.component.ts +++ /dev/null @@ -1,1025 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - HostListener, - inject, - QueryList, - ViewChildren, -} from '@angular/core'; -import { WorkContextService } from '../../work-context/work-context.service'; -import { DocumentModeService } from '../document-mode.service'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { map } from 'rxjs/operators'; -import { DocumentBlock, TaskBlock } from '../document-block.model'; -import { DocumentTaskBlockComponent } from '../document-task-block/document-task-block.component'; -import { DocumentTextBlockComponent } from '../document-text-block/document-text-block.component'; -import { DocumentHeadingBlockComponent } from '../document-heading-block/document-heading-block.component'; -import { DocumentDividerBlockComponent } from '../document-divider-block/document-divider-block.component'; -import { MatIcon } from '@angular/material/icon'; -import { CdkDragDrop, CdkDrag, CdkDropList, CdkDragHandle } from '@angular/cdk/drag-drop'; -import { TaskService } from '../../tasks/task.service'; -import { Task } from '../../tasks/task.model'; -import { MatDialog } from '@angular/material/dialog'; -import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component'; -import { T } from '../../../t.const'; - -interface SlashMenuItem { - label: string; - icon: string; - action: string; -} - -@Component({ - selector: 'document-view', - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - DocumentTaskBlockComponent, - DocumentTextBlockComponent, - DocumentHeadingBlockComponent, - DocumentDividerBlockComponent, - MatIcon, - CdkDropList, - CdkDrag, - CdkDragHandle, - ], - template: ` -
- @for (block of blocks(); track block.id; let i = $index) { -
-
- - -
-
- @switch (block.type) { - @case ('task') { - - } - @case ('text') { - - } - @case ('heading') { - - } - @case ('divider') { - - } - } -
-
- } -
- - @if (menuBlockId) { - - - } - `, - styles: [ - ` - :host { - display: block; - flex: 1; - } - - .document { - max-width: 720px; - margin: 0 auto; - padding: var(--s4) var(--s2) var(--s4) 60px; - cursor: default; - font-weight: 500; - color: var(--text-color-most-intense); - } - - .document.is-empty-document { - cursor: text; - min-height: 200px; - display: flex; - align-items: flex-start; - justify-content: flex-start; - } - - .document.is-empty-document::before { - content: 'Start typing, or press / for commands'; - color: var(--text-color-muted); - opacity: 0.4; - font-size: 16px; - pointer-events: none; - padding-top: var(--s2); - } - - .block-row { - display: flex; - align-items: center; - position: relative; - cursor: auto; - animation: block-enter 200ms ease-out; - } - - @keyframes block-enter { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } - } - - /* Context-aware spacing */ - .gutter { - display: flex; - align-items: center; - gap: 2px; - opacity: 0; - transition: opacity var(--transition-duration-s) var(--ani-standard-timing); - flex-shrink: 0; - width: 52px; - margin-left: -52px; - } - - .block-row:hover > .gutter, - .block-row:focus-within > .gutter { - opacity: 1; - } - - .gutter-btn { - width: 28px; - height: 28px; - line-height: 28px; - padding: 0; - border: none; - background: none; - color: var(--text-color-muted); - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - } - - .drag-btn { - cursor: grab; - } - - .type-btn { - cursor: pointer; - } - - .gutter-btn:hover { - color: var(--text-color); - background: var(--c-dark-10); - } - - :host-context(.isDarkTheme) .gutter-btn:hover { - background: var(--c-light-05); - } - - .gutter-btn .mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } - - .block-content { - flex: 1; - min-width: 0; - } - - .block-row.before-h1 > .block-content { - padding-bottom: var(--s2); - } - - .block-row.before-h2 > .block-content, - .block-row.before-h3 > .block-content { - padding-bottom: var(--s); - } - - .block-row.is-task-group > .block-content { - padding-top: var(--s-half); - padding-bottom: var(--s-half); - } - - .block-row.after-h1 > .block-content { - padding-top: var(--s); - } - - .block-row.after-h2 > .block-content, - .block-row.after-h3 > .block-content { - padding-top: var(--s-half); - } - - .block-row.is-divider.after-h1 > .block-content, - .block-row.is-divider.after-h2 > .block-content, - .block-row.is-divider.after-h3 > .block-content { - padding-top: 0; - } - - .block-row.is-last-task > .block-content { - padding-top: var(--s-half); - padding-bottom: var(--s); - } - - /* CDK Drag */ - .cdk-drag-preview { - background: var(--bg); - box-shadow: var(--whiteframe-shadow-6dp); - border-radius: var(--card-border-radius); - padding: 0 var(--s); - } - - .cdk-drag-placeholder { - opacity: 0; - position: relative; - height: 4px !important; - min-height: 0 !important; - overflow: hidden; - padding: 0 !important; - margin: 0 !important; - } - - .cdk-drag-placeholder::after { - content: ''; - position: absolute; - left: 0; - right: 0; - top: 0; - height: 3px; - border-radius: 2px; - background: var(--palette-primary-500); - opacity: 1; - } - - .cdk-drag-animating { - transition: transform var(--transition-duration-m) var(--ani-standard-timing); - } - - .cdk-drop-list-dragging .block-row:not(.cdk-drag-placeholder) { - transition: transform var(--transition-duration-m) var(--ani-standard-timing); - } - - /* Popup menu (slash menu + block context menu) */ - .menu-backdrop { - position: fixed; - inset: 0; - z-index: var(--z-backdrop); - } - - .popup-menu { - position: fixed; - z-index: calc(var(--z-backdrop) + 1); - background: var(--card-bg); - border-radius: 8px; - box-shadow: var(--whiteframe-shadow-6dp); - padding: 6px 0; - min-width: 200px; - max-height: 400px; - overflow-y: auto; - animation: menu-enter 150ms ease-out; - } - - @keyframes menu-enter { - from { - opacity: 0; - transform: scale(0.95) translateY(-4px); - } - to { - opacity: 1; - transform: scale(1) translateY(0); - } - } - - .menu-item { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 8px 14px; - border: none; - background: none; - cursor: pointer; - text-align: left; - color: var(--text-color); - font: inherit; - font-size: 14px; - font-weight: 400; - } - - .menu-item:hover, - .menu-item.is-active { - background: var(--c-dark-10); - } - - :host-context(.isDarkTheme) .menu-item:hover, - :host-context(.isDarkTheme) .menu-item.is-active { - background: var(--c-light-05); - } - - .menu-item .mat-icon { - color: var(--text-color-less-intense); - font-size: 18px; - width: 18px; - height: 18px; - } - - .menu-empty { - padding: 8px 14px; - color: var(--text-color-muted); - font-size: 14px; - } - `, - ], -}) -export class DocumentViewComponent { - @ViewChildren(DocumentTextBlockComponent) - textBlocks!: QueryList; - - @ViewChildren(DocumentHeadingBlockComponent) - headingBlocks!: QueryList; - - @ViewChildren(DocumentTaskBlockComponent) - taskBlocks!: QueryList; - - @ViewChildren(DocumentDividerBlockComponent) - dividerBlocks!: QueryList; - - private _workContextService = inject(WorkContextService); - private _documentModeService = inject(DocumentModeService); - private _taskService = inject(TaskService); - private _matDialog = inject(MatDialog); - - constructor() { - this._documentModeService.removeOrphanedTaskBlocks(); - this._documentModeService.syncMissingTasks(); - } - - // Unified menu state (slash menu + block context menu) - menuBlockId: string | null = null; - menuMode: 'slash' | 'block' = 'slash'; - menuTop = 0; - menuLeft = 0; - menuActiveIndex = 0; - menuFilter = ''; - - private readonly _slashInsertItems: SlashMenuItem[] = [ - { label: 'Task', icon: 'check_circle_outline', action: 'task' }, - { label: 'Paragraph', icon: 'segment', action: 'text' }, - { label: 'Heading 1', icon: 'title', action: 'h1' }, - { label: 'Heading 2', icon: 'text_fields', action: 'h2' }, - { label: 'Heading 3', icon: 'short_text', action: 'h3' }, - { label: 'Divider', icon: 'horizontal_rule', action: 'divider' }, - ]; - - private readonly _turnIntoItems: SlashMenuItem[] = [ - { label: 'Task', icon: 'check_circle_outline', action: 'turn-task' }, - { label: 'Paragraph', icon: 'segment', action: 'turn-text' }, - { label: 'Heading 1', icon: 'title', action: 'turn-h1' }, - { label: 'Heading 2', icon: 'text_fields', action: 'turn-h2' }, - { label: 'Heading 3', icon: 'short_text', action: 'turn-h3' }, - ]; - - private readonly _blockMenuItems: SlashMenuItem[] = [ - { label: 'Delete', icon: 'delete', action: 'delete' }, - { label: 'Duplicate', icon: 'content_copy', action: 'duplicate' }, - { label: 'Move up', icon: 'arrow_upward', action: 'move-up' }, - { label: 'Move down', icon: 'arrow_downward', action: 'move-down' }, - ]; - - filteredMenuItems: SlashMenuItem[] = []; - - blocks = toSignal( - this._workContextService.activeWorkContext$.pipe( - map((ctx) => (ctx.documentBlocks as DocumentBlock[]) || []), - ), - { initialValue: [] as DocumentBlock[] }, - ); - - @HostListener('keydown', ['$event']) - onHostKeydown(ev: KeyboardEvent): void { - // Handle menu keyboard navigation - if (this.menuBlockId) { - if (ev.key === 'ArrowDown') { - ev.preventDefault(); - if (this.filteredMenuItems.length > 0) { - this.menuActiveIndex = - (this.menuActiveIndex + 1) % this.filteredMenuItems.length; - } - return; - } - if (ev.key === 'ArrowUp') { - ev.preventDefault(); - if (this.filteredMenuItems.length > 0) { - this.menuActiveIndex = - (this.menuActiveIndex - 1 + this.filteredMenuItems.length) % - this.filteredMenuItems.length; - } - return; - } - if (ev.key === 'Enter') { - ev.preventDefault(); - if (this.filteredMenuItems.length > 0) { - this.onMenuSelect(this.filteredMenuItems[this.menuActiveIndex].action); - } - return; - } - if (ev.key === 'Escape') { - ev.preventDefault(); - this._closeMenuAndRestoreFocus(); - return; - } - } - - // Ctrl/Cmd+Shift+ArrowUp/Down to move blocks - if ((ev.ctrlKey || ev.metaKey) && ev.shiftKey) { - if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') { - ev.preventDefault(); - this._moveCurrentBlock(ev.key === 'ArrowUp' ? -1 : 1); - } - } - } - - onDocumentClick(ev: MouseEvent): void { - const currentBlocks = this.blocks(); - if (currentBlocks.length > 0) { - return; - } - - this._documentModeService.createTextBlock(''); - setTimeout(() => { - const updatedBlocks = this.blocks(); - const newLast = updatedBlocks[updatedBlocks.length - 1]; - if (newLast) { - this._focusBlock(newLast.id); - } - }); - } - - onDrop(event: CdkDragDrop): void { - if (event.previousIndex !== event.currentIndex) { - const block = this.blocks()[event.previousIndex]; - this._documentModeService.moveBlock(block.id, event.currentIndex); - } - } - - onContentChanged(blockId: string, content: string): void { - this._documentModeService.updateBlockContent(blockId, { content }); - } - - onTaskEnterOnEmpty(block: DocumentBlock): void { - // Empty task + Enter → convert to text paragraph, delete the empty task - if (block.type === 'task') { - const taskId = (block as TaskBlock).taskId; - this._deleteTaskEntity(taskId, () => { - this._convertBlock(block.id, 'text'); - setTimeout(() => this._focusBlock(block.id)); - }); - } - } - - onEnterPressed(afterBlockId: string): void { - // Tasks → new task, headings/text/dividers → new text block - const block = this.blocks().find((b) => b.id === afterBlockId); - if (block?.type === 'task') { - const taskId = this._taskService.add(''); - this._documentModeService.createTaskBlock(taskId, afterBlockId); - } else { - this._documentModeService.createTextBlock('', afterBlockId); - } - setTimeout(() => this._focusBlockAfter(afterBlockId)); - } - - onSplitAtCursor(blockId: string, ev: { before: string; after: string }): void { - const newBlockId = this._documentModeService.splitBlock(blockId, ev.before, ev.after); - setTimeout(() => this._focusBlock(newBlockId, 'start')); - } - - onTaskSplitAtCursor(block: DocumentBlock, ev: { before: string; after: string }): void { - if (block.type === 'task') { - const taskBlock = block as TaskBlock; - if (ev.before !== undefined) { - this._taskService.update(taskBlock.taskId, { title: ev.before }); - } - // Split creates a new task with the remaining text - const newTaskId = this._taskService.add(ev.after || ''); - this._documentModeService.createTaskBlock(newTaskId, block.id); - setTimeout(() => this._focusBlockAfter(block.id, 'start')); - } - } - - onBackspaceOnEmpty(blockId: string): void { - const currentBlocks = this.blocks(); - if (currentBlocks.length <= 1) return; - const idx = currentBlocks.findIndex((b) => b.id === blockId); - const block = currentBlocks[idx]; - - const removeAndFocus = (): void => { - this._documentModeService.removeBlock(blockId); - if (idx > 0) { - setTimeout(() => this._focusBlock(currentBlocks[idx - 1].id, 'end')); - } - }; - - if (block?.type === 'task') { - this._deleteTaskEntity((block as TaskBlock).taskId, removeAndFocus); - } else { - removeAndFocus(); - } - } - - onBackspaceAtStart(blockId: string, _content: string): void { - // Heading → convert to text first (like Super List / Notion) - const block = this.blocks().find((b) => b.id === blockId); - if (block?.type === 'heading') { - this._convertBlock(blockId, 'text'); - setTimeout(() => this._focusBlock(blockId, 'start')); - return; - } - const result = this._documentModeService.mergeBlockIntoPrevious(blockId); - if (result) { - setTimeout(() => this._focusBlockAtOffset(result.targetId, result.offset)); - } - } - - onMarkdownConvert(blockId: string, ev: { targetType: string; content: string }): void { - if (ev.targetType === 'divider') { - // Replace the text block with a divider, then create a new text block after it - this._documentModeService.convertBlock(blockId, 'divider', this._taskService); - this._documentModeService.createTextBlock('', blockId); - setTimeout(() => this._focusBlockAfter(blockId)); - } else if (ev.targetType === 'task') { - const taskId = this._taskService.add(ev.content || 'New task'); - this._documentModeService.convertBlock(blockId, 'task', { - add: () => taskId, - }); - setTimeout(() => this._focusBlock(blockId)); - } else { - // Heading conversions: update content and convert type - this._documentModeService.updateBlockContent(blockId, { content: ev.content }); - this._documentModeService.convertBlock(blockId, ev.targetType, this._taskService); - setTimeout(() => this._focusBlock(blockId, 'end')); - } - } - - onNavigateUp(blockId: string, offset?: number): void { - const currentBlocks = this.blocks(); - const idx = currentBlocks.findIndex((b) => b.id === blockId); - if (idx > 0) { - const targetId = currentBlocks[idx - 1].id; - if (offset !== undefined) { - this._focusBlockAtOffset(targetId, offset); - } else { - this._focusBlock(targetId, 'end'); - } - } - } - - onNavigateDown(blockId: string, offset?: number): void { - const currentBlocks = this.blocks(); - const idx = currentBlocks.findIndex((b) => b.id === blockId); - if (idx >= 0 && idx < currentBlocks.length - 1) { - const targetId = currentBlocks[idx + 1].id; - if (offset !== undefined) { - this._focusBlockAtOffset(targetId, offset); - } else { - this._focusBlock(targetId, 'start'); - } - } - } - - // --- Menu (shared slash + block context) --- - - showSlashMenu(el: HTMLElement, blockId: string): void { - const rect = el.getBoundingClientRect(); - this.menuTop = rect.bottom + 4; - this.menuLeft = rect.left; - this.menuBlockId = blockId; - this.menuMode = 'slash'; - this.menuFilter = ''; - this.menuActiveIndex = 0; - - const block = this.blocks().find((b) => b.id === blockId); - const hasContent = - block && - (block.type === 'text' || block.type === 'heading') && - (block as { content: string }).content.replace('/', '').trim().length > 0; - - this.filteredMenuItems = hasContent - ? [...this._turnIntoItems] - : [...this._slashInsertItems]; - } - - showBlockMenu(event: MouseEvent, block: DocumentBlock): void { - event.stopPropagation(); - const rect = (event.target as HTMLElement).getBoundingClientRect(); - this.menuTop = rect.bottom + 4; - this.menuLeft = rect.left; - this.menuBlockId = block.id; - this.menuMode = 'block'; - this.menuActiveIndex = 0; - this.menuFilter = ''; - this.filteredMenuItems = [...this._turnIntoItems, ...this._blockMenuItems]; - } - - onSlashFilterChanged(filter: string): void { - this.menuFilter = filter; - const lowerFilter = filter.toLowerCase(); - - const block = this.blocks().find((b) => b.id === this.menuBlockId); - const hasContent = - block && - (block.type === 'text' || block.type === 'heading') && - (block as { content: string }).content.replace(/\/.*$/, '').trim().length > 0; - - const items = hasContent ? this._turnIntoItems : this._slashInsertItems; - this.filteredMenuItems = items.filter((item) => - item.label.toLowerCase().includes(lowerFilter), - ); - this.menuActiveIndex = 0; - } - - closeMenu(): void { - this.menuBlockId = null; - this.menuFilter = ''; - } - - onMenuSelect(action: string): void { - const blockId = this.menuBlockId; - if (!blockId) return; - - this.closeMenu(); - - switch (action) { - case 'delete': - this._deleteBlock(blockId); - return; - case 'duplicate': - this._duplicateBlock(blockId); - return; - case 'move-up': - this._moveBlockByIndex(blockId, -1); - return; - case 'move-down': - this._moveBlockByIndex(blockId, 1); - return; - } - - if (action.startsWith('turn-')) { - const targetType = action.replace('turn-', ''); - const block = this.blocks().find((b) => b.id === blockId); - if (block?.type === 'task' && targetType !== 'task') { - this._deleteTaskEntity((block as TaskBlock).taskId, () => { - this._convertBlock(blockId, targetType); - setTimeout(() => this._focusBlock(blockId)); - }); - } else { - this._convertBlock(blockId, targetType); - setTimeout(() => this._focusBlock(blockId)); - } - return; - } - - this._clearSlashFromBlock(blockId); - - // If the block is now empty after clearing slash, convert in-place instead of inserting after - const block = this.blocks().find((b) => b.id === blockId); - const blockContent = - block && (block.type === 'text' || block.type === 'heading') - ? (block as { content: string }).content.trim() - : null; - const isEmptyBlock = blockContent === '' || blockContent === null; - - if (isEmptyBlock && action !== 'text') { - // Convert current block in-place - switch (action) { - case 'h1': - case 'h2': - case 'h3': - case 'task': - this._convertBlock(blockId, action); - setTimeout(() => this._focusBlock(blockId)); - return; - case 'divider': - this._convertBlock(blockId, 'divider'); - this._documentModeService.createTextBlock('', blockId); - setTimeout(() => this._focusBlockAfter(blockId)); - return; - } - } - - // Block has content: insert new block after - switch (action) { - case 'text': - this._documentModeService.createTextBlock('', blockId); - break; - case 'h1': - this._documentModeService.createHeadingBlock(1, '', blockId); - break; - case 'h2': - this._documentModeService.createHeadingBlock(2, '', blockId); - break; - case 'h3': - this._documentModeService.createHeadingBlock(3, '', blockId); - break; - case 'divider': { - const dividerId = this._documentModeService.createDividerBlock(blockId); - this._documentModeService.createTextBlock('', dividerId); - setTimeout(() => this._focusBlockAfter(dividerId)); - return; - } - case 'task': { - const taskId = this._taskService.add('New task'); - this._documentModeService.createTaskBlock(taskId, blockId); - break; - } - } - setTimeout(() => this._focusBlockAfter(blockId)); - } - - blockTypeIcon(block: DocumentBlock): string { - switch (block.type) { - case 'task': - return 'check_circle_outline'; - case 'heading': - return 'title'; - case 'divider': - return 'horizontal_rule'; - default: - return 'segment'; - } - } - - isLastTaskInSequence(index: number): boolean { - const b = this.blocks(); - if (b[index]?.type !== 'task') return false; - return index === b.length - 1 || b[index + 1]?.type !== 'task'; - } - - // --- Private helpers --- - - private _convertBlock(blockId: string, targetType: string): void { - this._documentModeService.convertBlock(blockId, targetType, this._taskService); - } - - private _deleteBlock(blockId: string): void { - const currentBlocks = this.blocks(); - if (currentBlocks.length <= 1) return; - const block = currentBlocks.find((b) => b.id === blockId); - if (!block) return; - if (block.type === 'task') { - this._deleteTaskEntity((block as TaskBlock).taskId, () => { - this._documentModeService.removeBlock(blockId); - }); - } else { - this._documentModeService.removeBlock(blockId); - } - } - - private _deleteTaskEntity(taskId: string, onDone: () => void): void { - const taskComp = this.taskBlocks?.find((c) => c.block().taskId === taskId); - const task = taskComp?.task(); - - if (task && !this._isBareTask(task)) { - this._matDialog - .open(DialogConfirmComponent, { - data: { - okTxt: T.F.TASK.D_CONFIRM_DELETE.OK, - message: T.F.TASK.D_CONFIRM_DELETE.MSG, - translateParams: { title: task.title || 'Untitled task' }, - }, - }) - .afterClosed() - .subscribe((isConfirm) => { - if (isConfirm) { - this._taskService.removeMultipleTasks([taskId]); - onDone(); - } - }); - } else { - this._taskService.removeMultipleTasks([taskId]); - onDone(); - } - } - - /** A bare task has nothing beyond a title and project — safe to delete without confirmation */ - private _isBareTask(task: Task): boolean { - return ( - !task.isDone && - task.timeSpent === 0 && - task.subTaskIds.length === 0 && - task.attachments.length === 0 && - task.tagIds.length === 0 && - !task.issueId && - !task.repeatCfgId && - !task.dueDay && - !task.dueWithTime && - !task.deadlineDay && - !task.deadlineWithTime && - !task.reminderId - ); - } - - private _duplicateBlock(blockId: string): void { - this._documentModeService.duplicateBlock(blockId, this._taskService); - } - - private _moveBlockByIndex(blockId: string, delta: number): void { - const currentBlocks = this.blocks(); - const idx = currentBlocks.findIndex((b) => b.id === blockId); - if (idx === -1) return; - const newIdx = idx + delta; - if (newIdx < 0 || newIdx >= currentBlocks.length) return; - this._documentModeService.moveBlock(blockId, newIdx); - } - - private _moveCurrentBlock(delta: number): void { - const blockId = this._findFocusedBlockId(); - if (blockId) { - this._moveBlockByIndex(blockId, delta); - setTimeout(() => this._focusBlock(blockId)); - } - } - - private _findFocusedBlockId(): string | null { - const active = document.activeElement; - if (!active) return null; - const blockRow = active.closest('[data-block-id]'); - return blockRow?.getAttribute('data-block-id') || null; - } - - private _clearSlashFromBlock(blockId: string): void { - // Clear slash text directly from the DOM (store-only updates don't sync back to contenteditable) - const textComp = this.textBlocks?.find((c) => c.block().id === blockId); - if (textComp) { - textComp.clearSlashText(); - return; - } - // Fallback for heading blocks: update store (heading blocks don't have slash support currently) - const block = this.blocks().find((b) => b.id === blockId); - if (block && block.type === 'heading') { - const content = (block as { content: string }).content; - const slashIdx = content.lastIndexOf('/'); - if (slashIdx >= 0) { - const cleaned = content.substring(0, slashIdx); - this._documentModeService.updateBlockContent(blockId, { content: cleaned }); - } - } - } - - private _closeMenuAndRestoreFocus(): void { - const blockId = this.menuBlockId; - if (this.menuMode === 'slash' && blockId) { - this._clearSlashFromBlock(blockId); - } - this.closeMenu(); - if (blockId) { - setTimeout(() => this._focusBlock(blockId, 'end')); - } - } - - private _focusBlockAfter(afterBlockId: string, position?: 'start' | 'end'): void { - const currentBlocks = this.blocks(); - const idx = currentBlocks.findIndex((b) => b.id === afterBlockId); - if (idx >= 0 && idx < currentBlocks.length - 1) { - this._focusBlock(currentBlocks[idx + 1].id, position); - } - } - - private _focusBlock(blockId: string, position?: 'start' | 'end'): void { - const block = this.blocks().find((b) => b.id === blockId); - if (!block) return; - - if (block.type === 'text') { - this.textBlocks?.find((c) => c.block().id === blockId)?.focus(position); - } else if (block.type === 'heading') { - this.headingBlocks?.find((c) => c.block().id === blockId)?.focus(position); - } else if (block.type === 'task') { - this.taskBlocks?.find((c) => c.block().id === blockId)?.focus(position); - } else if (block.type === 'divider') { - this.dividerBlocks?.find((c) => c.block().id === blockId)?.focus(); - } - } - - private _focusBlockAtOffset(blockId: string, offset: number): void { - const block = this.blocks().find((b) => b.id === blockId); - if (!block) return; - - if (block.type === 'text') { - this.textBlocks?.find((c) => c.block().id === blockId)?.focusAtOffset(offset); - } else if (block.type === 'heading') { - this.headingBlocks?.find((c) => c.block().id === blockId)?.focusAtOffset(offset); - } else if (block.type === 'task') { - this.taskBlocks?.find((c) => c.block().id === blockId)?.focusAtOffset(offset); - } else if (block.type === 'divider') { - this.dividerBlocks?.find((c) => c.block().id === blockId)?.focus(); - } - } -} diff --git a/src/app/features/document-mode/sanitize-block-html.ts b/src/app/features/document-mode/sanitize-block-html.ts deleted file mode 100644 index 03f23335a8..0000000000 --- a/src/app/features/document-mode/sanitize-block-html.ts +++ /dev/null @@ -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; -}; diff --git a/src/app/features/document-mode/store/document-mode.actions.ts b/src/app/features/document-mode/store/document-mode.actions.ts deleted file mode 100644 index 3609c89c5a..0000000000 --- a/src/app/features/document-mode/store/document-mode.actions.ts +++ /dev/null @@ -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, - }), -); diff --git a/src/app/features/onboarding/onboarding-presets.const.ts b/src/app/features/onboarding/onboarding-presets.const.ts index 10213a024f..a0ea9062b9 100644 --- a/src/app/features/onboarding/onboarding-presets.const.ts +++ b/src/app/features/onboarding/onboarding-presets.const.ts @@ -23,7 +23,6 @@ const BASE_FEATURES: AppFeaturesConfig = { isEnableUserProfiles: false, isHabitsEnabled: false, isFinishDayEnabled: false, - isDocumentModeEnabled: true, }; export const ONBOARDING_PRESETS: OnboardingPreset[] = [ diff --git a/src/app/features/project/store/project.effects.ts b/src/app/features/project/store/project.effects.ts index e1d0289f31..bd2ce9b433 100644 --- a/src/app/features/project/store/project.effects.ts +++ b/src/app/features/project/store/project.effects.ts @@ -103,12 +103,6 @@ export class ProjectEffects { () => this._actions$.pipe( ofType(updateProject.type), - filter(({ project }: { project: { changes?: Record } }) => { - 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', diff --git a/src/app/features/project/store/project.reducer.ts b/src/app/features/project/store/project.reducer.ts index 948cf4f0ea..5d3f386651 100644 --- a/src/app/features/project/store/project.reducer.ts +++ b/src/app/features/project/store/project.reducer.ts @@ -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( 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)), diff --git a/src/app/features/tag/store/tag.reducer.ts b/src/app/features/tag/store/tag.reducer.ts index 8159e405fd..775079aed1 100644 --- a/src/app/features/tag/store/tag.reducer.ts +++ b/src/app/features/tag/store/tag.reducer.ts @@ -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( 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)), diff --git a/src/app/features/work-context/work-context.const.ts b/src/app/features/work-context/work-context.const.ts index d09a9806c5..450a5da64c 100644 --- a/src/app/features/work-context/work-context.const.ts +++ b/src/app/features/work-context/work-context.const.ts @@ -46,8 +46,6 @@ export const WORK_CONTEXT_DEFAULT_COMMON: WorkContextCommon = { icon: null, id: '', title: '', - documentBlocks: [], - isDocumentMode: false, }; export const HUES = [ diff --git a/src/app/features/work-context/work-context.model.ts b/src/app/features/work-context/work-context.model.ts index 22293c5bf2..96620ed147 100644 --- a/src/app/features/work-context/work-context.model.ts +++ b/src/app/features/work-context/work-context.model.ts @@ -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; diff --git a/src/app/features/work-view/work-view.component.html b/src/app/features/work-view/work-view.component.html index 2fbb8d0ea8..5cbd4cc986 100644 --- a/src/app/features/work-view/work-view.component.html +++ b/src/app/features/work-view/work-view.component.html @@ -10,7 +10,7 @@ class="today" cdkScrollable > - @if (!(isDocumentMode() && isProjectContext()) && !pluginEmbedId()) { + @if (!pluginEmbedId()) {
@if (estimateRemainingToday() || workingToday()) {
- } @else if (isDocumentMode() && isProjectContext()) { - } @else {
@if (!(workContextService.isHasTasksToWorkOn$ | async)) { @@ -382,9 +380,7 @@ }
- @if ( - isShowBacklog() && !(isDocumentMode() && isProjectContext()) && !pluginEmbedId() - ) { + @if (isShowBacklog() && !pluginEmbedId()) {
!!ctx.isDocumentMode)), - { initialValue: false }, - ); - isDocumentMode = computed( - () => - this._isDocumentModeForContext() && - this._globalConfigService.appFeatures().isDocumentModeEnabled, - ); - isProjectContext = toSignal(this.workContextService.isActiveWorkContextProject$, { initialValue: false, }); diff --git a/src/app/op-log/core/action-types.enum.ts b/src/app/op-log/core/action-types.enum.ts index ba57bffc83..d9f7b0b9c9 100644 --- a/src/app/op-log/core/action-types.enum.ts +++ b/src/app/op-log/core/action-types.enum.ts @@ -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) diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 913b6e5752..63dbb312d1 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index d7517f2c91..3122653f65 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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": "Experimental feature: 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.",