mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-21 18:38:54 +00:00
refactor(document-mode): remove legacy Angular document-mode feature
Drops the in-app Angular document view (src/app/features/document-mode/) now that the TipTap-based plugin under packages/plugin-dev/document-mode/ is the canonical implementation: - Deletes the document-mode feature module (service, view, block components, sanitiser, actions). - Removes work-view and page-title hooks for the legacy toggle. - Drops isDocumentMode / documentBlocks fields from WorkContextCommon and the matching defaults; drops isDocumentModeEnabled from app-features config + onboarding presets. - Strips updateDocumentBlocksLocal / updateDocumentBlocksDelta from project and tag reducers, and the doc-keys filter from the project effects' update-snack. - Keeps the DOCUMENT_MODE_UPDATE_BLOCKS_DELTA enum + op-log code so historical op-log entries decode to a stable action type rather than the raw 'DU' code. The bundled-plugins/document-mode path in plugin.service.ts continues to point at the new TipTap plugin.
This commit is contained in:
parent
95debf535d
commit
de5e399abd
25 changed files with 22 additions and 3120 deletions
|
|
@ -16,7 +16,6 @@ import { TaskViewCustomizerService } from '../../../features/task-view-customize
|
|||
import { TaskViewCustomizerPanelComponent } from '../../../features/task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component';
|
||||
import { GlobalConfigService } from '../../../features/config/global-config.service';
|
||||
import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
|
||||
import { DocumentModeService } from '../../../features/document-mode/document-mode.service';
|
||||
|
||||
@Component({
|
||||
selector: 'page-title',
|
||||
|
|
@ -55,37 +54,23 @@ import { DocumentModeService } from '../../../features/document-mode/document-mo
|
|||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
@if (isWorkViewPage()) {
|
||||
@if (!isDocumentMode()) {
|
||||
<button
|
||||
class="task-filter-btn"
|
||||
[class.isCustomized]="taskViewCustomizerService.isCustomized()"
|
||||
[matMenuTriggerFor]="customizerPanel.menu"
|
||||
mat-icon-button
|
||||
matTooltip="{{
|
||||
T.GCF.KEYBOARD.TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL | translate
|
||||
}} {{
|
||||
kb.toggleTaskViewCustomizerPanel
|
||||
? '[' + kb.toggleTaskViewCustomizerPanel + ']'
|
||||
: ''
|
||||
}}"
|
||||
>
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
class="task-filter-btn"
|
||||
[class.isCustomized]="taskViewCustomizerService.isCustomized()"
|
||||
[matMenuTriggerFor]="customizerPanel.menu"
|
||||
mat-icon-button
|
||||
matTooltip="{{
|
||||
T.GCF.KEYBOARD.TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL | translate
|
||||
}} {{
|
||||
kb.toggleTaskViewCustomizerPanel
|
||||
? '[' + kb.toggleTaskViewCustomizerPanel + ']'
|
||||
: ''
|
||||
}}"
|
||||
>
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
</button>
|
||||
|
||||
<task-view-customizer-panel #customizerPanel></task-view-customizer-panel>
|
||||
}
|
||||
@if (isProjectContext() && isDocumentModeEnabled()) {
|
||||
<button
|
||||
class="doc-mode-btn"
|
||||
mat-icon-button
|
||||
(click)="documentModeService.toggleDocumentMode()"
|
||||
[matTooltip]="
|
||||
isDocumentMode() ? 'Switch to list view' : 'Switch to document view'
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ isDocumentMode() ? 'list' : 'article' }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<task-view-customizer-panel #customizerPanel></task-view-customizer-panel>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -191,7 +176,6 @@ export class PageTitleComponent {
|
|||
private _router = inject(Router);
|
||||
private _workContextService = inject(WorkContextService);
|
||||
readonly taskViewCustomizerService = inject(TaskViewCustomizerService);
|
||||
readonly documentModeService = inject(DocumentModeService);
|
||||
private readonly _configService = inject(GlobalConfigService);
|
||||
private _translateService = inject(TranslateService);
|
||||
|
||||
|
|
@ -202,17 +186,6 @@ export class PageTitleComponent {
|
|||
activeWorkContextTypeAndId = toSignal(
|
||||
this._workContextService.activeWorkContextTypeAndId$,
|
||||
);
|
||||
isDocumentMode = toSignal(
|
||||
this._workContextService.activeWorkContext$.pipe(map((ctx) => !!ctx.isDocumentMode)),
|
||||
{ initialValue: false },
|
||||
);
|
||||
isProjectContext = toSignal(this._workContextService.isActiveWorkContextProject$, {
|
||||
initialValue: false,
|
||||
});
|
||||
isDocumentModeEnabled = computed(
|
||||
() => this._configService.appFeatures().isDocumentModeEnabled,
|
||||
);
|
||||
|
||||
// Single source for the current URL path — all route-derived signals compute off this.
|
||||
// Query and fragment are stripped so end-anchored matchers work for e.g. `/config#plugins`.
|
||||
private _url$ = this._router.events.pipe(
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ export const ACTION_TYPE_TO_CODE: Record<ActionType, string> = {
|
|||
// GlobalConfig actions (C)
|
||||
[ActionType.GLOBAL_CONFIG_UPDATE_SECTION]: 'CU',
|
||||
|
||||
// DocumentMode actions (D)
|
||||
// DocumentMode actions (D) — feature removed, kept so historical op-log
|
||||
// entries decode to a stable action type rather than the raw 'DU' code.
|
||||
[ActionType.DOCUMENT_MODE_UPDATE_BLOCKS_DELTA]: 'DU',
|
||||
|
||||
// Metric actions (E)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
isEnableUserProfiles: false,
|
||||
isHabitsEnabled: true,
|
||||
isFinishDayEnabled: true,
|
||||
isDocumentModeEnabled: true,
|
||||
},
|
||||
localization: {
|
||||
lng: undefined,
|
||||
|
|
|
|||
|
|
@ -114,14 +114,6 @@ export const APP_FEATURES_FORM_CFG: ConfigFormSection<AppFeaturesConfig> = {
|
|||
icon: 'heart_check',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isDocumentModeEnabled',
|
||||
type: 'slide-toggle',
|
||||
templateOptions: {
|
||||
label: T.GCF.APP_FEATURES.DOCUMENT_MODE,
|
||||
icon: 'article',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isEnableUserProfiles',
|
||||
type: 'slide-toggle',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export type AppFeaturesConfig = Readonly<{
|
|||
isEnableUserProfiles: boolean;
|
||||
isHabitsEnabled: boolean;
|
||||
isFinishDayEnabled: boolean;
|
||||
isDocumentModeEnabled: boolean;
|
||||
}>;
|
||||
|
||||
export type MiscConfig = Readonly<{
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
export type DocumentBlockType = 'task' | 'text' | 'heading' | 'divider';
|
||||
|
||||
export interface DocumentBlockBase {
|
||||
id: string;
|
||||
type: DocumentBlockType;
|
||||
}
|
||||
|
||||
export interface TaskBlock extends DocumentBlockBase {
|
||||
type: 'task';
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export interface TextBlock extends DocumentBlockBase {
|
||||
type: 'text';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type HeadingLevel = 1 | 2 | 3;
|
||||
|
||||
export interface HeadingBlock extends DocumentBlockBase {
|
||||
type: 'heading';
|
||||
content: string;
|
||||
level: HeadingLevel;
|
||||
}
|
||||
|
||||
export interface DividerBlock extends DocumentBlockBase {
|
||||
type: 'divider';
|
||||
}
|
||||
|
||||
export type DocumentBlock = TaskBlock | TextBlock | HeadingBlock | DividerBlock;
|
||||
|
||||
export interface DocumentBlocksDelta {
|
||||
changedBlocks: DocumentBlock[];
|
||||
removedBlockIds: string[];
|
||||
blockOrder: string[];
|
||||
}
|
||||
|
||||
const blocksEqual = (a: DocumentBlock, b: DocumentBlock): boolean => {
|
||||
if (a.id !== b.id || a.type !== b.type) return false;
|
||||
switch (a.type) {
|
||||
case 'task':
|
||||
return a.taskId === (b as TaskBlock).taskId;
|
||||
case 'text':
|
||||
return a.content === (b as TextBlock).content;
|
||||
case 'heading':
|
||||
return (
|
||||
a.content === (b as HeadingBlock).content && a.level === (b as HeadingBlock).level
|
||||
);
|
||||
case 'divider':
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const orderEqual = (a: string[], b: string[]): boolean =>
|
||||
a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
|
||||
/**
|
||||
* Compute a minimal delta between two block arrays.
|
||||
* Returns null if nothing changed.
|
||||
*/
|
||||
export const computeBlocksDelta = (
|
||||
lastBlocks: DocumentBlock[],
|
||||
currentBlocks: DocumentBlock[],
|
||||
): DocumentBlocksDelta | null => {
|
||||
const lastMap = new Map(lastBlocks.map((b) => [b.id, b]));
|
||||
const currentIds = new Set(currentBlocks.map((b) => b.id));
|
||||
|
||||
const removedBlockIds = lastBlocks
|
||||
.filter((b) => !currentIds.has(b.id))
|
||||
.map((b) => b.id);
|
||||
|
||||
const changedBlocks = currentBlocks.filter((block) => {
|
||||
const prev = lastMap.get(block.id);
|
||||
return !prev || !blocksEqual(prev, block);
|
||||
});
|
||||
|
||||
const blockOrder = currentBlocks.map((b) => b.id);
|
||||
const lastOrder = lastBlocks.map((b) => b.id);
|
||||
const orderChanged = !orderEqual(lastOrder, blockOrder);
|
||||
|
||||
if (changedBlocks.length === 0 && removedBlockIds.length === 0 && !orderChanged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { changedBlocks, removedBlockIds, blockOrder };
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a delta to an existing block array.
|
||||
* Used by reducers to reconstruct documentBlocks from a delta operation.
|
||||
* Preserves blocks not in blockOrder (e.g. concurrent remote adds).
|
||||
*/
|
||||
export const applyDocumentBlocksDelta = (
|
||||
existing: DocumentBlock[],
|
||||
delta: DocumentBlocksDelta,
|
||||
): DocumentBlock[] => {
|
||||
const blockMap = new Map(existing.map((b) => [b.id, b]));
|
||||
|
||||
for (const block of delta.changedBlocks) {
|
||||
blockMap.set(block.id, block);
|
||||
}
|
||||
for (const id of delta.removedBlockIds) {
|
||||
blockMap.delete(id);
|
||||
}
|
||||
|
||||
const ordered: DocumentBlock[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of delta.blockOrder) {
|
||||
const block = blockMap.get(id);
|
||||
if (block) {
|
||||
ordered.push(block);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
// Append blocks not in order (concurrent remote adds)
|
||||
for (const [id, block] of blockMap) {
|
||||
if (!seen.has(id)) {
|
||||
ordered.push(block);
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
};
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { DividerBlock } from '../document-block.model';
|
||||
|
||||
@Component({
|
||||
selector: 'document-divider-block',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [],
|
||||
template: `
|
||||
<hr
|
||||
#dividerEl
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
/>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
padding: var(--s) 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--text-color-muted);
|
||||
opacity: 0.3;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
hr:focus {
|
||||
opacity: 0.6;
|
||||
border-top-color: var(--palette-primary-500, #6495ed);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DocumentDividerBlockComponent {
|
||||
block = input.required<DividerBlock>();
|
||||
enterPressed = output<void>();
|
||||
deleteBlock = output<void>();
|
||||
navigateUp = output<void>();
|
||||
navigateDown = output<void>();
|
||||
|
||||
dividerEl = viewChild<ElementRef<HTMLHRElement>>('dividerEl');
|
||||
|
||||
focus(): void {
|
||||
this.dividerEl()?.nativeElement.focus();
|
||||
}
|
||||
|
||||
onKeydown(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'Backspace' || ev.key === 'Delete') {
|
||||
ev.preventDefault();
|
||||
this.deleteBlock.emit();
|
||||
}
|
||||
if (ev.key === 'ArrowUp') {
|
||||
ev.preventDefault();
|
||||
this.navigateUp.emit();
|
||||
}
|
||||
if (ev.key === 'ArrowDown') {
|
||||
ev.preventDefault();
|
||||
this.navigateDown.emit();
|
||||
}
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
this.enterPressed.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { HeadingBlock } from '../document-block.model';
|
||||
import { sanitizeBlockHtml } from '../sanitize-block-html';
|
||||
|
||||
@Component({
|
||||
selector: 'document-heading-block',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [],
|
||||
template: `
|
||||
@switch (block().level) {
|
||||
@case (1) {
|
||||
<h1
|
||||
#editableEl
|
||||
contenteditable="true"
|
||||
[attr.data-placeholder]="'Heading 1'"
|
||||
(input)="onInput($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
(paste)="onPaste($event)"
|
||||
></h1>
|
||||
}
|
||||
@case (2) {
|
||||
<h2
|
||||
#editableEl
|
||||
contenteditable="true"
|
||||
[attr.data-placeholder]="'Heading 2'"
|
||||
(input)="onInput($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
(paste)="onPaste($event)"
|
||||
></h2>
|
||||
}
|
||||
@case (3) {
|
||||
<h3
|
||||
#editableEl
|
||||
contenteditable="true"
|
||||
[attr.data-placeholder]="'Heading 3'"
|
||||
(input)="onInput($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
(paste)="onPaste($event)"
|
||||
></h3>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
outline: none;
|
||||
cursor: text;
|
||||
margin: 0;
|
||||
padding: 3px 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-most-intense);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
h1:empty:focus::before,
|
||||
h2:empty:focus::before,
|
||||
h3:empty:focus::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-color-muted);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DocumentHeadingBlockComponent {
|
||||
block = input.required<HeadingBlock>();
|
||||
contentChanged = output<string>();
|
||||
enterPressed = output<void>();
|
||||
splitAtCursor = output<{ before: string; after: string }>();
|
||||
backspaceOnEmpty = output<void>();
|
||||
backspaceAtStart = output<string>();
|
||||
navigateUp = output<number>();
|
||||
navigateDown = output<number>();
|
||||
|
||||
editableEl = viewChild<ElementRef<HTMLElement>>('editableEl');
|
||||
private _initialized = signal(false);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
const content = this.block().content;
|
||||
if (el) {
|
||||
// Skip if this is a local edit (user is actively typing)
|
||||
if (this._initialized() && document.activeElement === el) return;
|
||||
if (content.includes('<')) {
|
||||
el.innerHTML = sanitizeBlockHtml(content);
|
||||
} else {
|
||||
el.textContent = content;
|
||||
}
|
||||
this._initialized.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
focus(position?: 'start' | 'end'): void {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
if (position && el.childNodes.length > 0) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const textNode = el.firstChild!;
|
||||
if (position === 'end') {
|
||||
sel.collapse(textNode, textNode.textContent?.length || 0);
|
||||
} else {
|
||||
sel.collapse(textNode, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
focusAtOffset(offset: number): void {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const pos = this._findNodeAtOffset(el, offset);
|
||||
if (pos) {
|
||||
sel.collapse(pos.node, pos.offset);
|
||||
}
|
||||
}
|
||||
|
||||
onInput(ev: Event): void {
|
||||
const el = ev.target as HTMLElement;
|
||||
const text = el.textContent || '';
|
||||
const hasFormatting = el.querySelector('b, strong, i, em, a, u');
|
||||
this.contentChanged.emit(hasFormatting ? sanitizeBlockHtml(el.innerHTML) : text);
|
||||
}
|
||||
|
||||
onPaste(ev: ClipboardEvent): void {
|
||||
ev.preventDefault();
|
||||
const text = ev.clipboardData?.getData('text/plain') || '';
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
|
||||
onKeydown(ev: KeyboardEvent): void {
|
||||
// Inline formatting shortcuts
|
||||
if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey) {
|
||||
if (ev.key === 'b') {
|
||||
ev.preventDefault();
|
||||
document.execCommand('bold');
|
||||
this._emitContent();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'i') {
|
||||
ev.preventDefault();
|
||||
document.execCommand('italic');
|
||||
this._emitContent();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
const fullText = el.textContent || '';
|
||||
const offset = this._getTextOffset(el, range);
|
||||
const before = fullText.substring(0, offset);
|
||||
const after = fullText.substring(offset);
|
||||
this.splitAtCursor.emit({ before, after });
|
||||
} else {
|
||||
this.enterPressed.emit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'Backspace' || ev.key === 'Delete') {
|
||||
const el = ev.target as HTMLElement;
|
||||
const text = el.textContent || '';
|
||||
if (!text) {
|
||||
ev.preventDefault();
|
||||
this.backspaceOnEmpty.emit();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Backspace') {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.isCollapsed && this._isAtStart(el, sel)) {
|
||||
ev.preventDefault();
|
||||
this.backspaceAtStart.emit(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
|
||||
if (this._shouldNavigateAcrossBlocks(ev.key)) {
|
||||
ev.preventDefault();
|
||||
const offset = this._getCurrentOffset();
|
||||
if (ev.key === 'ArrowUp') {
|
||||
this.navigateUp.emit(offset);
|
||||
} else {
|
||||
this.navigateDown.emit(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _emitContent(): void {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const hasFormatting = el.querySelector('b, strong, i, em, a, u');
|
||||
this.contentChanged.emit(
|
||||
hasFormatting ? sanitizeBlockHtml(el.innerHTML) : el.textContent || '',
|
||||
);
|
||||
}
|
||||
|
||||
private _getCurrentOffset(): number {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return 0;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return 0;
|
||||
return this._getTextOffset(el, sel.getRangeAt(0));
|
||||
}
|
||||
|
||||
private _getTextOffset(container: HTMLElement, range: Range): number {
|
||||
const preRange = document.createRange();
|
||||
preRange.selectNodeContents(container);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
return preRange.toString().length;
|
||||
}
|
||||
|
||||
private _findNodeAtOffset(
|
||||
container: Node,
|
||||
target: number,
|
||||
): { node: Node; offset: number } | null {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
let remaining = target;
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const len = node.textContent?.length || 0;
|
||||
if (remaining <= len) {
|
||||
return { node, offset: remaining };
|
||||
}
|
||||
remaining -= len;
|
||||
}
|
||||
const lastNode = this._lastTextNode(container);
|
||||
if (lastNode) {
|
||||
return { node: lastNode, offset: lastNode.textContent?.length || 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _lastTextNode(container: Node): Text | null {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
let last: Text | null = null;
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
last = node;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private _isAtStart(el: HTMLElement, sel: Selection): boolean {
|
||||
if (!sel.rangeCount) return false;
|
||||
const range = sel.getRangeAt(0);
|
||||
return this._getTextOffset(el, range) === 0;
|
||||
}
|
||||
|
||||
private _shouldNavigateAcrossBlocks(key: 'ArrowUp' | 'ArrowDown'): boolean {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return false;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.isCollapsed || !sel.rangeCount) return false;
|
||||
|
||||
if (!el.textContent) return true;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const textOffset = this._getTextOffset(el, range);
|
||||
const totalLength = el.textContent.length;
|
||||
|
||||
if (key === 'ArrowUp' && textOffset === 0) return true;
|
||||
if (key === 'ArrowDown' && textOffset === totalLength) return true;
|
||||
|
||||
const marker = document.createElement('span');
|
||||
marker.textContent = '\u200b';
|
||||
range.insertNode(marker);
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
marker.remove();
|
||||
sel.collapse(range.startContainer, range.startOffset);
|
||||
|
||||
if (!markerRect.height) return false;
|
||||
|
||||
const tolerance = 4;
|
||||
if (key === 'ArrowUp') {
|
||||
return markerRect.top - elRect.top < tolerance;
|
||||
} else {
|
||||
return elRect.bottom - markerRect.bottom < tolerance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,520 +0,0 @@
|
|||
import { inject, Injectable, OnDestroy } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { WorkContextService } from '../work-context/work-context.service';
|
||||
import { WorkContextType } from '../work-context/work-context.model';
|
||||
import {
|
||||
computeBlocksDelta,
|
||||
DividerBlock,
|
||||
DocumentBlock,
|
||||
HeadingBlock,
|
||||
HeadingLevel,
|
||||
TaskBlock,
|
||||
TextBlock,
|
||||
} from './document-block.model';
|
||||
import { updateProject } from '../project/store/project.actions';
|
||||
import { selectProjectFeatureState } from '../project/store/project.selectors';
|
||||
import { updateTag } from '../tag/store/tag.actions';
|
||||
import { selectTagFeatureState } from '../tag/store/tag.reducer';
|
||||
import {
|
||||
updateDocumentBlocksDelta,
|
||||
updateDocumentBlocksLocal,
|
||||
} from './store/document-mode.actions';
|
||||
import { uuidv7 } from '../../util/uuid-v7';
|
||||
|
||||
const SYNC_DEBOUNCE_MS = 5_000;
|
||||
|
||||
interface ActiveContext {
|
||||
id: string;
|
||||
type: WorkContextType;
|
||||
title: string;
|
||||
taskIds: string[];
|
||||
documentBlocks?: DocumentBlock[];
|
||||
isDocumentMode?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DocumentModeService implements OnDestroy {
|
||||
private _store = inject(Store);
|
||||
private _workContextService = inject(WorkContextService);
|
||||
private _activeWorkContext = toSignal(this._workContextService.activeWorkContext$);
|
||||
private _projectState = this._store.selectSignal(selectProjectFeatureState);
|
||||
private _tagState = this._store.selectSignal(selectTagFeatureState);
|
||||
private _syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private _pendingSync: {
|
||||
contextId: string;
|
||||
contextType: WorkContextType;
|
||||
} | null = null;
|
||||
private _lastPersistedBlocks: DocumentBlock[] = [];
|
||||
private _lastPersistedContextId: string | null = null;
|
||||
private _beforeUnloadHandler = (): void => this._flushSync();
|
||||
private _visibilityHandler = (): void => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this._flushSync();
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
document.addEventListener('visibilitychange', this._visibilityHandler);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
||||
this._flushSync();
|
||||
}
|
||||
|
||||
toggleDocumentMode(): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const isDocumentMode = !ctx.isDocumentMode;
|
||||
let documentBlocks = ctx.documentBlocks;
|
||||
|
||||
if (isDocumentMode && (!documentBlocks || documentBlocks.length === 0)) {
|
||||
const titleBlock: HeadingBlock = {
|
||||
id: uuidv7(),
|
||||
type: 'heading',
|
||||
content: ctx.title || '',
|
||||
level: 1,
|
||||
};
|
||||
// Seed document blocks from existing tasks
|
||||
const taskBlocks: DocumentBlock[] = (ctx.taskIds || []).map((taskId) => ({
|
||||
id: uuidv7(),
|
||||
type: 'task' as const,
|
||||
taskId,
|
||||
}));
|
||||
const dividerBlock: DividerBlock = { id: uuidv7(), type: 'divider' };
|
||||
documentBlocks = [
|
||||
titleBlock,
|
||||
dividerBlock,
|
||||
...taskBlocks,
|
||||
{ id: uuidv7(), type: 'text' as const, content: '' },
|
||||
];
|
||||
}
|
||||
|
||||
// Toggle is always persisted immediately via full updateProject/updateTag
|
||||
this._dispatchPersistent(id, type, { isDocumentMode, documentBlocks });
|
||||
this._updateSnapshot(id, documentBlocks || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all tasks from taskIds have a corresponding TaskBlock in documentBlocks.
|
||||
* Appends missing tasks at the end (before the trailing text block if one exists).
|
||||
*/
|
||||
syncMissingTasks(): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = ctx.documentBlocks || [];
|
||||
const existingTaskIds = new Set(
|
||||
blocks.filter((b) => b.type === 'task').map((b) => (b as TaskBlock).taskId),
|
||||
);
|
||||
const missingTaskIds = (ctx.taskIds || []).filter((tid) => !existingTaskIds.has(tid));
|
||||
if (missingTaskIds.length === 0) return;
|
||||
|
||||
const newBlocks: DocumentBlock[] = missingTaskIds.map((taskId) => ({
|
||||
id: uuidv7(),
|
||||
type: 'task' as const,
|
||||
taskId,
|
||||
}));
|
||||
|
||||
// Insert before the last block if it's an empty text block, otherwise append
|
||||
const updated = [...blocks];
|
||||
const lastBlock = updated[updated.length - 1];
|
||||
if (lastBlock?.type === 'text' && !(lastBlock as TextBlock).content) {
|
||||
updated.splice(updated.length - 1, 0, ...newBlocks);
|
||||
} else {
|
||||
updated.push(...newBlocks);
|
||||
}
|
||||
|
||||
this._dispatchLocal(id, type, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove task blocks whose tasks no longer exist (e.g. deleted from list mode).
|
||||
*/
|
||||
removeOrphanedTaskBlocks(): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = ctx.documentBlocks || [];
|
||||
const validTaskIds = new Set(ctx.taskIds || []);
|
||||
const cleaned = blocks.filter(
|
||||
(b) => b.type !== 'task' || validTaskIds.has((b as TaskBlock).taskId),
|
||||
);
|
||||
if (cleaned.length < blocks.length) {
|
||||
this._dispatchLocal(id, type, cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
addBlock(block: DocumentBlock, afterBlockId?: string): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = [...(ctx.documentBlocks || [])];
|
||||
if (afterBlockId) {
|
||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||
if (idx === -1) {
|
||||
blocks.push(block);
|
||||
} else {
|
||||
blocks.splice(idx + 1, 0, block);
|
||||
}
|
||||
} else {
|
||||
blocks.push(block);
|
||||
}
|
||||
this._dispatchLocal(id, type, blocks);
|
||||
}
|
||||
|
||||
removeBlock(blockId: string): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = (ctx.documentBlocks || []).filter((b) => b.id !== blockId);
|
||||
this._dispatchLocal(id, type, blocks);
|
||||
}
|
||||
|
||||
moveBlock(blockId: string, newIndex: number): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = [...(ctx.documentBlocks || [])];
|
||||
const oldIndex = blocks.findIndex((b) => b.id === blockId);
|
||||
if (oldIndex === -1) return;
|
||||
const [block] = blocks.splice(oldIndex, 1);
|
||||
blocks.splice(newIndex, 0, block);
|
||||
this._dispatchLocal(id, type, blocks);
|
||||
}
|
||||
|
||||
updateBlockContent(blockId: string, changes: Partial<TextBlock | HeadingBlock>): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = (ctx.documentBlocks || []).map((b) =>
|
||||
b.id === blockId ? ({ ...b, ...changes } as DocumentBlock) : b,
|
||||
);
|
||||
this._dispatchLocal(id, type, blocks);
|
||||
}
|
||||
|
||||
createTextBlock(content: string = '', afterBlockId?: string): void {
|
||||
const block: TextBlock = { id: uuidv7(), type: 'text', content };
|
||||
this.addBlock(block, afterBlockId);
|
||||
}
|
||||
|
||||
createHeadingBlock(
|
||||
level: HeadingLevel,
|
||||
content: string = '',
|
||||
afterBlockId?: string,
|
||||
): void {
|
||||
const block: HeadingBlock = { id: uuidv7(), type: 'heading', content, level };
|
||||
this.addBlock(block, afterBlockId);
|
||||
}
|
||||
|
||||
createTaskBlock(taskId: string, afterBlockId?: string): void {
|
||||
const block: TaskBlock = { id: uuidv7(), type: 'task', taskId };
|
||||
this.addBlock(block, afterBlockId);
|
||||
}
|
||||
|
||||
createDividerBlock(afterBlockId?: string): string {
|
||||
const block: DividerBlock = { id: uuidv7(), type: 'divider' };
|
||||
this.addBlock(block, afterBlockId);
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a text/heading block at the cursor position.
|
||||
* The current block keeps `before`, a new text block gets `after`.
|
||||
* Returns the new block's id for focusing.
|
||||
*/
|
||||
splitBlock(blockId: string, before: string, after: string): string {
|
||||
const newBlockId = uuidv7();
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return newBlockId;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = (ctx.documentBlocks || []).map((b) => {
|
||||
if (b.id !== blockId) return b;
|
||||
if (b.type === 'text') return { ...b, content: before } as TextBlock;
|
||||
if (b.type === 'heading') return { ...b, content: before } as HeadingBlock;
|
||||
return b;
|
||||
});
|
||||
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
const sourceBlock = (ctx.documentBlocks || []).find((b) => b.id === blockId);
|
||||
if (idx === -1 || !sourceBlock) return newBlockId;
|
||||
|
||||
// After split, new block is always text (like Super List / Notion)
|
||||
const newBlock: DocumentBlock = { id: newBlockId, type: 'text', content: after };
|
||||
|
||||
blocks.splice(idx + 1, 0, newBlock);
|
||||
this._dispatchLocal(id, type, blocks);
|
||||
return newBlockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a block's content into the previous block, then remove it.
|
||||
* Returns the target block id and the offset where content was appended.
|
||||
*/
|
||||
mergeBlockIntoPrevious(blockId: string): { targetId: string; offset: number } | null {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return null;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = [...(ctx.documentBlocks || [])];
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx <= 0) return null;
|
||||
|
||||
const current = blocks[idx];
|
||||
const prev = blocks[idx - 1];
|
||||
|
||||
// Only merge into text/heading blocks
|
||||
if (prev.type !== 'text' && prev.type !== 'heading') return null;
|
||||
|
||||
const prevContent = (prev as TextBlock | HeadingBlock).content;
|
||||
const currentContent =
|
||||
current.type === 'text' || current.type === 'heading'
|
||||
? (current as TextBlock | HeadingBlock).content
|
||||
: '';
|
||||
|
||||
const offset = prevContent.length;
|
||||
const merged = prevContent + currentContent;
|
||||
|
||||
const updated = blocks
|
||||
.map((b) => {
|
||||
if (b.id === prev.id) {
|
||||
return { ...b, content: merged } as DocumentBlock;
|
||||
}
|
||||
return b;
|
||||
})
|
||||
.filter((b) => b.id !== blockId);
|
||||
|
||||
this._dispatchLocal(id, type, updated);
|
||||
return { targetId: prev.id, offset };
|
||||
}
|
||||
|
||||
appendTaskBlockIfMissing(taskId: string): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = ctx.documentBlocks || [];
|
||||
const exists = blocks.some((b) => b.type === 'task' && b.taskId === taskId);
|
||||
if (!exists) {
|
||||
const newBlock: TaskBlock = { id: uuidv7(), type: 'task', taskId };
|
||||
this._dispatchLocal(id, type, [...blocks, newBlock]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a block to a different type.
|
||||
* text/heading → task: creates a new task, replaces block with TaskBlock.
|
||||
* task → text: extracts title, replaces TaskBlock with TextBlock.
|
||||
* text ↔ heading: preserves content.
|
||||
*/
|
||||
convertBlock(
|
||||
blockId: string,
|
||||
targetType: string,
|
||||
taskService: { add: (title: string) => string },
|
||||
): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = [...(ctx.documentBlocks || [])];
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
let content = '';
|
||||
if (block.type === 'text' || block.type === 'heading') {
|
||||
content = (block as TextBlock | HeadingBlock).content;
|
||||
}
|
||||
|
||||
let newBlock: DocumentBlock;
|
||||
switch (targetType) {
|
||||
case 'text':
|
||||
if (block.type === 'task') {
|
||||
// We can't easily get the task title here synchronously,
|
||||
// so just create an empty text block
|
||||
newBlock = { id: block.id, type: 'text', content: '' };
|
||||
} else {
|
||||
newBlock = { id: block.id, type: 'text', content };
|
||||
}
|
||||
break;
|
||||
case 'h1':
|
||||
newBlock = { id: block.id, type: 'heading', content, level: 1 as HeadingLevel };
|
||||
break;
|
||||
case 'h2':
|
||||
newBlock = { id: block.id, type: 'heading', content, level: 2 as HeadingLevel };
|
||||
break;
|
||||
case 'h3':
|
||||
newBlock = { id: block.id, type: 'heading', content, level: 3 as HeadingLevel };
|
||||
break;
|
||||
case 'task': {
|
||||
const taskId = taskService.add(content || 'New task');
|
||||
newBlock = { id: block.id, type: 'task', taskId };
|
||||
break;
|
||||
}
|
||||
case 'divider':
|
||||
newBlock = { id: block.id, type: 'divider' };
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
blocks[idx] = newBlock;
|
||||
this._dispatchLocal(id, type, blocks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a block, inserting the copy immediately after the original.
|
||||
*/
|
||||
duplicateBlock(blockId: string, taskService: { add: (title: string) => string }): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = [...(ctx.documentBlocks || [])];
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
let newBlock: DocumentBlock;
|
||||
|
||||
if (block.type === 'task') {
|
||||
// Create a new task for the duplicate
|
||||
const newTaskId = taskService.add('New task');
|
||||
newBlock = { id: uuidv7(), type: 'task', taskId: newTaskId };
|
||||
} else if (block.type === 'heading') {
|
||||
newBlock = {
|
||||
id: uuidv7(),
|
||||
type: 'heading',
|
||||
content: (block as HeadingBlock).content,
|
||||
level: (block as HeadingBlock).level,
|
||||
};
|
||||
} else if (block.type === 'divider') {
|
||||
newBlock = { id: uuidv7(), type: 'divider' };
|
||||
} else {
|
||||
newBlock = {
|
||||
id: uuidv7(),
|
||||
type: 'text',
|
||||
content: (block as TextBlock).content,
|
||||
};
|
||||
}
|
||||
|
||||
blocks.splice(idx + 1, 0, newBlock);
|
||||
this._dispatchLocal(id, type, blocks);
|
||||
}
|
||||
|
||||
removeTaskBlock(taskId: string): void {
|
||||
const active = this._getActiveContext();
|
||||
if (!active) return;
|
||||
const { id, type, ctx } = active;
|
||||
const blocks = (ctx.documentBlocks || []).filter(
|
||||
(b) => !(b.type === 'task' && b.taskId === taskId),
|
||||
);
|
||||
this._dispatchLocal(id, type, blocks);
|
||||
}
|
||||
|
||||
private _getActiveContext(): {
|
||||
id: string;
|
||||
type: WorkContextType;
|
||||
ctx: ActiveContext;
|
||||
} | null {
|
||||
const ctx = this._activeWorkContext();
|
||||
if (!ctx) return null;
|
||||
return {
|
||||
id: ctx.id,
|
||||
type: ctx.type,
|
||||
ctx: {
|
||||
id: ctx.id,
|
||||
type: ctx.type,
|
||||
title: ctx.title,
|
||||
taskIds: ctx.taskIds,
|
||||
documentBlocks: ctx.documentBlocks,
|
||||
isDocumentMode: ctx.isDocumentMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch non-persistent action for immediate local UI update,
|
||||
* then schedule a debounced persistent sync.
|
||||
*/
|
||||
private _dispatchLocal(
|
||||
contextId: string,
|
||||
contextType: WorkContextType,
|
||||
documentBlocks: DocumentBlock[],
|
||||
): void {
|
||||
this._store.dispatch(
|
||||
updateDocumentBlocksLocal({
|
||||
contextId,
|
||||
contextType: contextType === WorkContextType.PROJECT ? 'PROJECT' : 'TAG',
|
||||
documentBlocks,
|
||||
}),
|
||||
);
|
||||
this._scheduleSyncDebounced(contextId, contextType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch persistent action immediately (for toggle, etc.)
|
||||
*/
|
||||
private _dispatchPersistent(
|
||||
contextId: string,
|
||||
contextType: WorkContextType,
|
||||
changes: { documentBlocks?: DocumentBlock[]; isDocumentMode?: boolean },
|
||||
): void {
|
||||
if (contextType === WorkContextType.PROJECT) {
|
||||
this._store.dispatch(updateProject({ project: { id: contextId, changes } }));
|
||||
} else {
|
||||
this._store.dispatch(
|
||||
updateTag({ tag: { id: contextId, changes }, isSkipSnack: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleSyncDebounced(contextId: string, contextType: WorkContextType): void {
|
||||
// C1: flush pending sync if it's for a different context
|
||||
if (this._pendingSync && this._pendingSync.contextId !== contextId) {
|
||||
this._flushSync();
|
||||
}
|
||||
this._pendingSync = { contextId, contextType };
|
||||
if (this._syncTimeout) {
|
||||
clearTimeout(this._syncTimeout);
|
||||
}
|
||||
this._syncTimeout = setTimeout(() => this._flushSync(), SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private _flushSync(): void {
|
||||
if (!this._pendingSync) return;
|
||||
const { contextId, contextType } = this._pendingSync;
|
||||
this._pendingSync = null;
|
||||
if (this._syncTimeout) {
|
||||
clearTimeout(this._syncTimeout);
|
||||
this._syncTimeout = null;
|
||||
}
|
||||
|
||||
// Read blocks by entity ID so flush works even after context switch
|
||||
const entity =
|
||||
contextType === WorkContextType.PROJECT
|
||||
? this._projectState().entities[contextId]
|
||||
: this._tagState().entities[contextId];
|
||||
if (!entity) return;
|
||||
|
||||
const currentBlocks = entity.documentBlocks || [];
|
||||
const lastBlocks =
|
||||
this._lastPersistedContextId === contextId ? this._lastPersistedBlocks : [];
|
||||
const delta = computeBlocksDelta(lastBlocks, currentBlocks);
|
||||
if (!delta) return; // Nothing changed since last persist
|
||||
|
||||
this._store.dispatch(
|
||||
updateDocumentBlocksDelta({
|
||||
contextId,
|
||||
contextType: contextType === WorkContextType.PROJECT ? 'PROJECT' : 'TAG',
|
||||
delta,
|
||||
}),
|
||||
);
|
||||
this._updateSnapshot(contextId, currentBlocks);
|
||||
}
|
||||
|
||||
private _updateSnapshot(contextId: string, blocks: DocumentBlock[]): void {
|
||||
this._lastPersistedContextId = contextId;
|
||||
this._lastPersistedBlocks = blocks;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { TaskBlock } from '../document-block.model';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectTaskById } from '../../tasks/store/task.selectors';
|
||||
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { TaskService } from '../../tasks/task.service';
|
||||
import { Task } from '../../tasks/task.model';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TagListComponent } from '../../tag/tag-list/tag-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'document-task-block',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon, TagListComponent],
|
||||
template: `
|
||||
@if (task(); as t) {
|
||||
<div
|
||||
class="task-row"
|
||||
[class.is-done]="t.isDone"
|
||||
>
|
||||
<div
|
||||
class="done-toggle"
|
||||
[class.is-done]="t.isDone"
|
||||
(click)="toggleDone(t)"
|
||||
role="checkbox"
|
||||
[attr.aria-checked]="t.isDone"
|
||||
tabindex="0"
|
||||
(keydown.enter)="toggleDone(t); $event.stopPropagation()"
|
||||
(keydown.space)="
|
||||
toggleDone(t); $event.stopPropagation(); $event.preventDefault()
|
||||
"
|
||||
>
|
||||
<svg
|
||||
class="done-toggle-svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
class="done-circle"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<polyline
|
||||
class="done-check"
|
||||
points="8,12 11,15 16,9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
#titleEl
|
||||
class="task-title"
|
||||
contenteditable="true"
|
||||
(input)="onTitleInput($event, t)"
|
||||
(blur)="onTitleBlur($event, t)"
|
||||
(keydown)="onKeydown($event)"
|
||||
></span>
|
||||
<button
|
||||
class="detail-btn"
|
||||
(click)="openDetail(t)"
|
||||
>
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (t.tagIds?.length || t.projectId || t.repeatCfgId || t.issueId) {
|
||||
<div class="tags-row">
|
||||
<tag-list [task]="t"></tag-list>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
@keyframes draw-check {
|
||||
from {
|
||||
stroke-dashoffset: 16;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.done-toggle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.done-toggle-svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.3;
|
||||
transition: opacity var(--transition-duration-m) var(--ani-standard-timing);
|
||||
}
|
||||
|
||||
.done-circle {
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.done-check {
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 16;
|
||||
stroke-dashoffset: 16;
|
||||
}
|
||||
|
||||
.done-toggle.is-done .done-toggle-svg {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.done-toggle.is-done .done-check {
|
||||
animation: draw-check 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
.done-toggle:hover .done-toggle-svg {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.done-toggle.is-done:hover .done-toggle-svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
line-height: 1.5;
|
||||
color: inherit;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
.task-title:empty:focus::before {
|
||||
content: 'To-do';
|
||||
color: var(--text-color-muted);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.is-done .task-title {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-color-muted);
|
||||
}
|
||||
|
||||
.detail-btn {
|
||||
opacity: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-color-muted);
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: opacity var(--transition-duration-s) var(--ani-standard-timing);
|
||||
}
|
||||
|
||||
.task-row:hover .detail-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.detail-btn:hover {
|
||||
color: var(--text-color);
|
||||
background: var(--c-dark-10);
|
||||
}
|
||||
|
||||
:host-context(.isDarkTheme) .detail-btn:hover {
|
||||
background: var(--c-light-05);
|
||||
}
|
||||
|
||||
.detail-btn .mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.tags-row {
|
||||
padding-left: 34px;
|
||||
margin-top: -2px;
|
||||
opacity: 0.55;
|
||||
filter: grayscale(1);
|
||||
transition:
|
||||
opacity var(--transition-duration-s) var(--ani-standard-timing),
|
||||
filter var(--transition-duration-s) var(--ani-standard-timing);
|
||||
}
|
||||
|
||||
:host(:focus-within) .tags-row {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DocumentTaskBlockComponent {
|
||||
block = input.required<TaskBlock>();
|
||||
enterPressed = output<void>();
|
||||
enterOnEmpty = output<void>();
|
||||
splitAtCursor = output<{ before: string; after: string }>();
|
||||
backspaceOnEmpty = output<void>();
|
||||
backspaceAtStart = output<string>();
|
||||
navigateUp = output<number>();
|
||||
navigateDown = output<number>();
|
||||
|
||||
private _store = inject(Store);
|
||||
private _taskService = inject(TaskService);
|
||||
private _destroyRef = inject(DestroyRef);
|
||||
private _lastSetTitle = '';
|
||||
private _titleSaveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private _pendingTitle: { taskId: string; title: string } | null = null;
|
||||
|
||||
titleEl = viewChild<ElementRef<HTMLSpanElement>>('titleEl');
|
||||
|
||||
task = toSignal(
|
||||
toObservable(this.block).pipe(
|
||||
switchMap((b) => this._store.select(selectTaskById, { id: b.taskId })),
|
||||
),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const t = this.task();
|
||||
const el = this.titleEl()?.nativeElement;
|
||||
if (t && el && t.title !== this._lastSetTitle) {
|
||||
if (el.textContent !== t.title) {
|
||||
el.textContent = t.title;
|
||||
}
|
||||
this._lastSetTitle = t.title;
|
||||
}
|
||||
});
|
||||
this._destroyRef.onDestroy(() => {
|
||||
if (this._titleSaveTimeout) {
|
||||
clearTimeout(this._titleSaveTimeout);
|
||||
this._flushTitleSave();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
focus(position?: 'start' | 'end'): void {
|
||||
const el = this.titleEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
if (position && el.childNodes.length > 0) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const textNode = el.firstChild!;
|
||||
if (position === 'end') {
|
||||
sel.collapse(textNode, textNode.textContent?.length || 0);
|
||||
} else {
|
||||
sel.collapse(textNode, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
focusAtOffset(offset: number): void {
|
||||
const el = this.titleEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const pos = this._findNodeAtOffset(el, offset);
|
||||
if (pos) {
|
||||
sel.collapse(pos.node, pos.offset);
|
||||
}
|
||||
}
|
||||
|
||||
toggleDone(task: Task): void {
|
||||
this._taskService.update(task.id, { isDone: !task.isDone });
|
||||
}
|
||||
|
||||
openDetail(task: Task): void {
|
||||
this._taskService.setSelectedId(task.id);
|
||||
}
|
||||
|
||||
onTitleInput(_ev: Event, task: Task): void {
|
||||
const el = this.titleEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const newTitle = el.textContent?.trim() || '';
|
||||
if (newTitle === task.title) return;
|
||||
if (this._titleSaveTimeout) clearTimeout(this._titleSaveTimeout);
|
||||
this._pendingTitle = { taskId: task.id, title: newTitle };
|
||||
this._titleSaveTimeout = setTimeout(() => this._flushTitleSave(), 1000);
|
||||
}
|
||||
|
||||
onTitleBlur(ev: FocusEvent, task: Task): void {
|
||||
if (this._titleSaveTimeout) {
|
||||
clearTimeout(this._titleSaveTimeout);
|
||||
this._titleSaveTimeout = null;
|
||||
}
|
||||
const newTitle = (ev.target as HTMLElement).textContent?.trim();
|
||||
if (newTitle && newTitle !== task.title) {
|
||||
this._lastSetTitle = newTitle;
|
||||
this._taskService.update(task.id, { title: newTitle });
|
||||
}
|
||||
this._pendingTitle = null;
|
||||
}
|
||||
|
||||
onKeydown(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
const el = this.titleEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
const fullText = el.textContent || '';
|
||||
const offset = this._getTextOffset(el, range);
|
||||
const before = fullText.substring(0, offset);
|
||||
const after = fullText.substring(offset);
|
||||
if (before || after) {
|
||||
this.splitAtCursor.emit({ before, after });
|
||||
} else {
|
||||
this.enterOnEmpty.emit();
|
||||
}
|
||||
} else {
|
||||
this.enterOnEmpty.emit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'Backspace' || ev.key === 'Delete') {
|
||||
const el = this.titleEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const text = el.textContent || '';
|
||||
if (!text) {
|
||||
ev.preventDefault();
|
||||
this.backspaceOnEmpty.emit();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Backspace') {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.isCollapsed && this._isAtStart(el, sel)) {
|
||||
ev.preventDefault();
|
||||
this.backspaceAtStart.emit(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
|
||||
if (this._shouldNavigateAcrossBlocks(ev.key)) {
|
||||
ev.preventDefault();
|
||||
const offset = this._getCurrentOffset();
|
||||
if (ev.key === 'ArrowUp') {
|
||||
this.navigateUp.emit(offset);
|
||||
} else {
|
||||
this.navigateDown.emit(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getCurrentOffset(): number {
|
||||
const el = this.titleEl()?.nativeElement;
|
||||
if (!el) return 0;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return 0;
|
||||
return this._getTextOffset(el, sel.getRangeAt(0));
|
||||
}
|
||||
|
||||
private _findNodeAtOffset(
|
||||
container: Node,
|
||||
target: number,
|
||||
): { node: Node; offset: number } | null {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
let remaining = target;
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const len = node.textContent?.length || 0;
|
||||
if (remaining <= len) {
|
||||
return { node, offset: remaining };
|
||||
}
|
||||
remaining -= len;
|
||||
}
|
||||
const lastNode = this._lastTextNode(container);
|
||||
if (lastNode) {
|
||||
return { node: lastNode, offset: lastNode.textContent?.length || 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _lastTextNode(container: Node): Text | null {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
let last: Text | null = null;
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
last = node;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private _getTextOffset(container: HTMLElement, range: Range): number {
|
||||
const preRange = document.createRange();
|
||||
preRange.selectNodeContents(container);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
return preRange.toString().length;
|
||||
}
|
||||
|
||||
private _isAtStart(el: HTMLElement, sel: Selection): boolean {
|
||||
if (!sel.rangeCount) return false;
|
||||
const range = sel.getRangeAt(0);
|
||||
return this._getTextOffset(el, range) === 0;
|
||||
}
|
||||
|
||||
private _flushTitleSave(): void {
|
||||
if (this._pendingTitle) {
|
||||
this._lastSetTitle = this._pendingTitle.title;
|
||||
this._taskService.update(this._pendingTitle.taskId, {
|
||||
title: this._pendingTitle.title,
|
||||
});
|
||||
this._pendingTitle = null;
|
||||
}
|
||||
this._titleSaveTimeout = null;
|
||||
}
|
||||
|
||||
private _shouldNavigateAcrossBlocks(key: 'ArrowUp' | 'ArrowDown'): boolean {
|
||||
const el = this.titleEl()?.nativeElement;
|
||||
if (!el) return false;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.isCollapsed || !sel.rangeCount) return false;
|
||||
|
||||
if (!el.textContent) return true;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const textOffset = this._getTextOffset(el, range);
|
||||
const totalLength = el.textContent.length;
|
||||
|
||||
if (key === 'ArrowUp' && textOffset === 0) return true;
|
||||
if (key === 'ArrowDown' && textOffset === totalLength) return true;
|
||||
|
||||
const marker = document.createElement('span');
|
||||
marker.textContent = '\u200b';
|
||||
range.insertNode(marker);
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
marker.remove();
|
||||
sel.collapse(range.startContainer, range.startOffset);
|
||||
|
||||
if (!markerRect.height) return false;
|
||||
|
||||
const tolerance = 4;
|
||||
if (key === 'ArrowUp') {
|
||||
return markerRect.top - elRect.top < tolerance;
|
||||
} else {
|
||||
return elRect.bottom - markerRect.bottom < tolerance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,361 +0,0 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { TextBlock } from '../document-block.model';
|
||||
import { sanitizeBlockHtml } from '../sanitize-block-html';
|
||||
|
||||
@Component({
|
||||
selector: 'document-text-block',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [],
|
||||
template: `
|
||||
<div
|
||||
#editableEl
|
||||
class="text-block"
|
||||
contenteditable="true"
|
||||
data-placeholder="Type something, or press / for commands"
|
||||
(input)="onInput($event)"
|
||||
(keydown)="onKeydown($event)"
|
||||
(paste)="onPaste($event)"
|
||||
></div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.text-block {
|
||||
outline: none;
|
||||
cursor: text;
|
||||
padding: 3px 0;
|
||||
line-height: 1.6;
|
||||
color: inherit;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
min-height: 1.6em;
|
||||
}
|
||||
|
||||
.text-block:empty:focus::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-color-muted);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DocumentTextBlockComponent {
|
||||
block = input.required<TextBlock>();
|
||||
contentChanged = output<string>();
|
||||
enterPressed = output<void>();
|
||||
splitAtCursor = output<{ before: string; after: string }>();
|
||||
backspaceOnEmpty = output<void>();
|
||||
backspaceAtStart = output<string>();
|
||||
markdownConvert = output<{ targetType: string; content: string }>();
|
||||
slashTyped = output<HTMLElement>();
|
||||
slashFilterChanged = output<string>();
|
||||
navigateUp = output<number>();
|
||||
navigateDown = output<number>();
|
||||
|
||||
editableEl = viewChild<ElementRef<HTMLDivElement>>('editableEl');
|
||||
private _initialized = signal(false);
|
||||
private _slashActive = false;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
const content = this.block().content;
|
||||
if (el) {
|
||||
// Skip if this is a local edit (user is actively typing)
|
||||
if (this._initialized() && document.activeElement === el) return;
|
||||
if (content.includes('<')) {
|
||||
el.innerHTML = sanitizeBlockHtml(content);
|
||||
} else {
|
||||
el.textContent = content;
|
||||
}
|
||||
this._initialized.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
focus(position?: 'start' | 'end'): void {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
if (position && el.childNodes.length > 0) {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const textNode = el.firstChild!;
|
||||
if (position === 'end') {
|
||||
sel.collapse(textNode, textNode.textContent?.length || 0);
|
||||
} else {
|
||||
sel.collapse(textNode, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
focusAtOffset(offset: number): void {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const pos = this._findNodeAtOffset(el, offset);
|
||||
if (pos) {
|
||||
sel.collapse(pos.node, pos.offset);
|
||||
}
|
||||
}
|
||||
|
||||
onInput(ev: Event): void {
|
||||
const el = ev.target as HTMLElement;
|
||||
const text = el.textContent || '';
|
||||
// Emit innerHTML if there are formatting elements, otherwise plain text
|
||||
const hasFormatting = el.querySelector('b, strong, i, em, a, u');
|
||||
this.contentChanged.emit(hasFormatting ? sanitizeBlockHtml(el.innerHTML) : text);
|
||||
|
||||
// Detect markdown shortcuts at line start
|
||||
const md = this._detectMarkdownShortcut(text);
|
||||
if (md) {
|
||||
this.markdownConvert.emit(md);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._slashActive) {
|
||||
const slashIdx = text.lastIndexOf('/');
|
||||
if (slashIdx >= 0) {
|
||||
const filter = text.substring(slashIdx + 1);
|
||||
if (filter.includes(' ')) {
|
||||
this._slashActive = false;
|
||||
} else {
|
||||
this.slashFilterChanged.emit(filter);
|
||||
}
|
||||
} else {
|
||||
this._slashActive = false;
|
||||
}
|
||||
} else if (text.endsWith('/')) {
|
||||
this._slashActive = true;
|
||||
this.slashTyped.emit(el);
|
||||
}
|
||||
}
|
||||
|
||||
onPaste(ev: ClipboardEvent): void {
|
||||
ev.preventDefault();
|
||||
const text = ev.clipboardData?.getData('text/plain') || '';
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
|
||||
cancelSlash(): void {
|
||||
this._slashActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the slash (and any filter text after it) from the DOM element directly.
|
||||
* Needed because the contenteditable element is the DOM source of truth after init.
|
||||
*/
|
||||
clearSlashText(): void {
|
||||
this._slashActive = false;
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const text = el.textContent || '';
|
||||
const slashIdx = text.lastIndexOf('/');
|
||||
if (slashIdx >= 0) {
|
||||
const cleaned = text.substring(0, slashIdx);
|
||||
el.textContent = cleaned;
|
||||
this.contentChanged.emit(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
onKeydown(ev: KeyboardEvent): void {
|
||||
// Inline formatting shortcuts
|
||||
if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey) {
|
||||
if (ev.key === 'b') {
|
||||
ev.preventDefault();
|
||||
document.execCommand('bold');
|
||||
this._emitContent();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'i') {
|
||||
ev.preventDefault();
|
||||
document.execCommand('italic');
|
||||
this._emitContent();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
const fullText = el.textContent || '';
|
||||
const offset = this._getTextOffset(el, range);
|
||||
const before = fullText.substring(0, offset);
|
||||
const after = fullText.substring(offset);
|
||||
this.splitAtCursor.emit({ before, after });
|
||||
} else {
|
||||
this.enterPressed.emit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'Backspace' || ev.key === 'Delete') {
|
||||
const el = ev.target as HTMLElement;
|
||||
const text = el.textContent || '';
|
||||
if (!text) {
|
||||
ev.preventDefault();
|
||||
this.backspaceOnEmpty.emit();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Backspace') {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.isCollapsed && this._isAtStart(el, sel)) {
|
||||
ev.preventDefault();
|
||||
this.backspaceAtStart.emit(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
|
||||
if (this._shouldNavigateAcrossBlocks(ev.key)) {
|
||||
ev.preventDefault();
|
||||
const offset = this._getCurrentOffset();
|
||||
if (ev.key === 'ArrowUp') {
|
||||
this.navigateUp.emit(offset);
|
||||
} else {
|
||||
this.navigateDown.emit(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _emitContent(): void {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const hasFormatting = el.querySelector('b, strong, i, em, a, u');
|
||||
this.contentChanged.emit(
|
||||
hasFormatting ? sanitizeBlockHtml(el.innerHTML) : el.textContent || '',
|
||||
);
|
||||
}
|
||||
|
||||
private _detectMarkdownShortcut(
|
||||
text: string,
|
||||
): { targetType: string; content: string } | null {
|
||||
if (text.startsWith('### ')) {
|
||||
return { targetType: 'h3', content: text.substring(4) };
|
||||
}
|
||||
if (text.startsWith('## ')) {
|
||||
return { targetType: 'h2', content: text.substring(3) };
|
||||
}
|
||||
if (text.startsWith('# ')) {
|
||||
return { targetType: 'h1', content: text.substring(2) };
|
||||
}
|
||||
if (text.startsWith('[] ') || text.startsWith('- ')) {
|
||||
return { targetType: 'task', content: text.substring(text.indexOf(' ') + 1) };
|
||||
}
|
||||
if (text === '---') {
|
||||
return { targetType: 'divider', content: '' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _getCurrentOffset(): number {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return 0;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return 0;
|
||||
return this._getTextOffset(el, sel.getRangeAt(0));
|
||||
}
|
||||
|
||||
private _findNodeAtOffset(
|
||||
container: Node,
|
||||
target: number,
|
||||
): { node: Node; offset: number } | null {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
let remaining = target;
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const len = node.textContent?.length || 0;
|
||||
if (remaining <= len) {
|
||||
return { node, offset: remaining };
|
||||
}
|
||||
remaining -= len;
|
||||
}
|
||||
// Offset exceeds content — place at end of last text node
|
||||
const lastNode = this._lastTextNode(container);
|
||||
if (lastNode) {
|
||||
return { node: lastNode, offset: lastNode.textContent?.length || 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _lastTextNode(container: Node): Text | null {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
let last: Text | null = null;
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
last = node;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private _getTextOffset(container: HTMLElement, range: Range): number {
|
||||
const preRange = document.createRange();
|
||||
preRange.selectNodeContents(container);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
return preRange.toString().length;
|
||||
}
|
||||
|
||||
private _isAtStart(el: HTMLElement, sel: Selection): boolean {
|
||||
if (!sel.rangeCount) return false;
|
||||
const range = sel.getRangeAt(0);
|
||||
return this._getTextOffset(el, range) === 0;
|
||||
}
|
||||
|
||||
private _shouldNavigateAcrossBlocks(key: 'ArrowUp' | 'ArrowDown'): boolean {
|
||||
const el = this.editableEl()?.nativeElement;
|
||||
if (!el) return false;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.isCollapsed || !sel.rangeCount) return false;
|
||||
|
||||
if (!el.textContent) return true;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
const textOffset = this._getTextOffset(el, range);
|
||||
const totalLength = el.textContent.length;
|
||||
|
||||
// At absolute start/end → always navigate
|
||||
if (key === 'ArrowUp' && textOffset === 0) return true;
|
||||
if (key === 'ArrowDown' && textOffset === totalLength) return true;
|
||||
|
||||
// For multi-line content, use rect-based detection with a temp marker
|
||||
// (collapsed range rects are often zero-height and unreliable)
|
||||
const marker = document.createElement('span');
|
||||
marker.textContent = '\u200b';
|
||||
range.insertNode(marker);
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
marker.remove();
|
||||
// Restore selection after DOM mutation
|
||||
sel.collapse(range.startContainer, range.startOffset);
|
||||
|
||||
if (!markerRect.height) return false;
|
||||
|
||||
const tolerance = 4;
|
||||
if (key === 'ArrowUp') {
|
||||
return markerRect.top - elRect.top < tolerance;
|
||||
} else {
|
||||
return elRect.bottom - markerRect.bottom < tolerance;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
|
@ -23,7 +23,6 @@ const BASE_FEATURES: AppFeaturesConfig = {
|
|||
isEnableUserProfiles: false,
|
||||
isHabitsEnabled: false,
|
||||
isFinishDayEnabled: false,
|
||||
isDocumentModeEnabled: true,
|
||||
};
|
||||
|
||||
export const ONBOARDING_PRESETS: OnboardingPreset[] = [
|
||||
|
|
|
|||
|
|
@ -103,12 +103,6 @@ export class ProjectEffects {
|
|||
() =>
|
||||
this._actions$.pipe(
|
||||
ofType(updateProject.type),
|
||||
filter(({ project }: { project: { changes?: Record<string, unknown> } }) => {
|
||||
if (!project.changes) return true;
|
||||
const keys = Object.keys(project.changes);
|
||||
const docKeys = new Set(['documentBlocks', 'isDocumentMode']);
|
||||
return keys.some((k) => !docKeys.has(k));
|
||||
}),
|
||||
tap(() => {
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
|
|
|
|||
|
|
@ -13,11 +13,6 @@ import {
|
|||
moveTaskUpInTodayList,
|
||||
} from '../../work-context/store/work-context-meta.actions';
|
||||
import { moveItemAfterAnchor } from '../../work-context/store/work-context-meta.helper';
|
||||
import {
|
||||
updateDocumentBlocksDelta,
|
||||
updateDocumentBlocksLocal,
|
||||
} from '../../document-mode/store/document-mode.actions';
|
||||
import { applyDocumentBlocksDelta } from '../../document-mode/document-block.model';
|
||||
import {
|
||||
arrayMoveLeftUntil,
|
||||
arrayMoveRightUntil,
|
||||
|
|
@ -153,25 +148,6 @@ export const projectReducer = createReducer<ProjectState>(
|
|||
|
||||
on(updateProject, (state, { project }) => projectAdapter.updateOne(project, state)),
|
||||
|
||||
on(updateDocumentBlocksLocal, (state, { contextId, contextType, documentBlocks }) => {
|
||||
if (contextType !== 'PROJECT') return state;
|
||||
return projectAdapter.updateOne(
|
||||
{ id: contextId, changes: { documentBlocks } },
|
||||
state,
|
||||
);
|
||||
}),
|
||||
|
||||
on(updateDocumentBlocksDelta, (state, { contextId, contextType, delta }) => {
|
||||
if (contextType !== 'PROJECT') return state;
|
||||
const entity = state.entities[contextId];
|
||||
if (!entity) return state;
|
||||
const documentBlocks = applyDocumentBlocksDelta(entity.documentBlocks || [], delta);
|
||||
return projectAdapter.updateOne(
|
||||
{ id: contextId, changes: { documentBlocks } },
|
||||
state,
|
||||
);
|
||||
}),
|
||||
|
||||
// on(deleteProjects, (state, { ids }) => projectAdapter.removeMany(ids, state)),
|
||||
on(loadProjects, (state, { projects }) => projectAdapter.setAll(projects, state)),
|
||||
|
||||
|
|
|
|||
|
|
@ -40,11 +40,6 @@ import {
|
|||
updateTagOrder,
|
||||
} from './tag.actions';
|
||||
import { Log } from '../../../core/log';
|
||||
import {
|
||||
updateDocumentBlocksDelta,
|
||||
updateDocumentBlocksLocal,
|
||||
} from '../../document-mode/store/document-mode.actions';
|
||||
import { applyDocumentBlocksDelta } from '../../document-mode/document-block.model';
|
||||
|
||||
export const TAG_FEATURE_NAME = 'tag';
|
||||
const WORK_CONTEXT_TYPE: WorkContextType = WorkContextType.TAG;
|
||||
|
|
@ -352,22 +347,6 @@ export const tagReducer = createReducer<TagState>(
|
|||
|
||||
on(updateTag, (state: TagState, { tag }) => tagAdapter.updateOne(tag, state)),
|
||||
|
||||
on(
|
||||
updateDocumentBlocksLocal,
|
||||
(state: TagState, { contextId, contextType, documentBlocks }) => {
|
||||
if (contextType !== 'TAG') return state;
|
||||
return tagAdapter.updateOne({ id: contextId, changes: { documentBlocks } }, state);
|
||||
},
|
||||
),
|
||||
|
||||
on(updateDocumentBlocksDelta, (state: TagState, { contextId, contextType, delta }) => {
|
||||
if (contextType !== 'TAG') return state;
|
||||
const entity = state.entities[contextId];
|
||||
if (!entity) return state;
|
||||
const documentBlocks = applyDocumentBlocksDelta(entity.documentBlocks || [], delta);
|
||||
return tagAdapter.updateOne({ id: contextId, changes: { documentBlocks } }, state);
|
||||
}),
|
||||
|
||||
on(deleteTag, (state: TagState, { id }) => tagAdapter.removeOne(id, state)),
|
||||
|
||||
on(deleteTags, (state: TagState, { ids }) => tagAdapter.removeMany(ids, state)),
|
||||
|
|
|
|||
|
|
@ -46,8 +46,6 @@ export const WORK_CONTEXT_DEFAULT_COMMON: WorkContextCommon = {
|
|||
icon: null,
|
||||
id: '',
|
||||
title: '',
|
||||
documentBlocks: [],
|
||||
isDocumentMode: false,
|
||||
};
|
||||
|
||||
export const HUES = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
class="today"
|
||||
cdkScrollable
|
||||
>
|
||||
@if (!(isDocumentMode() && isProjectContext()) && !pluginEmbedId()) {
|
||||
@if (!pluginEmbedId()) {
|
||||
<header class="work-view-header">
|
||||
@if (estimateRemainingToday() || workingToday()) {
|
||||
<div
|
||||
|
|
@ -84,8 +84,6 @@
|
|||
[showFullUI]="false"
|
||||
[skipCleanupOnDestroy]="true"
|
||||
></plugin-index>
|
||||
} @else if (isDocumentMode() && isProjectContext()) {
|
||||
<document-view></document-view>
|
||||
} @else {
|
||||
<div class="task-list-wrapper">
|
||||
@if (!(workContextService.isHasTasksToWorkOn$ | async)) {
|
||||
|
|
@ -382,9 +380,7 @@
|
|||
}
|
||||
</div>
|
||||
|
||||
@if (
|
||||
isShowBacklog() && !(isDocumentMode() && isProjectContext()) && !pluginEmbedId()
|
||||
) {
|
||||
@if (isShowBacklog() && !pluginEmbedId()) {
|
||||
<div
|
||||
#splitBottomEl
|
||||
[style.user-focus]="splitInputPos === 100 ? 'none' : ''"
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ import { RepeatCfgPreviewComponent } from '../task-repeat-cfg/repeat-cfg-preview
|
|||
import { recordSearchNavDebug } from '../../util/search-nav-debug';
|
||||
import { dragDelayForTouch } from '../../util/input-intent';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { DocumentViewComponent } from '../document-mode/document-view/document-view.component';
|
||||
import { PluginIndexComponent } from '../../plugins/ui/plugin-index/plugin-index.component';
|
||||
import { PluginBridgeService } from '../../plugins/plugin-bridge.service';
|
||||
|
||||
|
|
@ -121,7 +120,6 @@ import { PluginBridgeService } from '../../plugins/plugin-bridge.service';
|
|||
FinishDayBtnComponent,
|
||||
ScheduledDateGroupPipe,
|
||||
RepeatCfgPreviewComponent,
|
||||
DocumentViewComponent,
|
||||
PluginIndexComponent,
|
||||
],
|
||||
})
|
||||
|
|
@ -147,16 +145,6 @@ export class WorkViewComponent implements OnInit, OnDestroy {
|
|||
private _pluginBridge = inject(PluginBridgeService);
|
||||
protected readonly dragDelayForTouch = dragDelayForTouch;
|
||||
|
||||
private _isDocumentModeForContext = toSignal(
|
||||
this.workContextService.activeWorkContext$.pipe(map((ctx) => !!ctx.isDocumentMode)),
|
||||
{ initialValue: false },
|
||||
);
|
||||
isDocumentMode = computed(
|
||||
() =>
|
||||
this._isDocumentModeForContext() &&
|
||||
this._globalConfigService.appFeatures().isDocumentModeEnabled,
|
||||
);
|
||||
|
||||
isProjectContext = toSignal(this.workContextService.isActiveWorkContextProject$, {
|
||||
initialValue: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -2110,7 +2110,6 @@
|
|||
"SYNC_BUTTON": "Sync button",
|
||||
"TIME_TRACKING": "Stopwatch time tracking",
|
||||
"TITLE": "App Features",
|
||||
"DOCUMENT_MODE": "Document mode",
|
||||
"USER_PROFILES": "User profiles (experimental — might be removed)",
|
||||
"USER_PROFILES_HINT": "Allows you to create and switch between different user profiles, each with separate settings, tasks, and sync configurations. The profile management button will appear in the top-right corner when enabled. Note: Disabling this feature will hide the UI but preserve your profile data. (Experimental feature. No guarantees. Make sure to have a backup!)",
|
||||
"USER_PROFILES_WARNING": "<strong>Experimental feature:</strong> This feature is still in development and may be removed or significantly changed in a future update. Use at your own risk and make sure to keep backups of your data.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue