diff --git a/.gitignore b/.gitignore index fc2fcd8..85812ab 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ data.json .DS_Store temp .idea +references/* diff --git a/src/features/file-histoy/effects/apply-file-history-effect.ts b/src/features/file-histoy/effects/apply-file-history-effect.ts new file mode 100644 index 0000000..1d635fb --- /dev/null +++ b/src/features/file-histoy/effects/apply-file-history-effect.ts @@ -0,0 +1,36 @@ +import { fileHistoryStore } from 'src/features/file-histoy/file-history-store'; +import { stores } from 'src/view/helpers/stores-cache'; + +export const applyFileHistoryEffect = () => { + return fileHistoryStore.subscribe((state, action) => { + if (!action) return; + if ( + action.type === 'UNDO_REDO_SNAPSHOT' || + action.type === 'SELECT_SNAPSHOT' + ) { + const path = action.payload.path; + const store = stores[path]; + if (!store) throw new Error(`store is undefined: ${path}`); + const document = state.documents[path]; + if (!document.activeSnapshotId) + throw new Error(`no active snapshot: ${path}`); + const snapshot = document.snapshots.find( + (s) => s.id === document.activeSnapshotId, + ); + if (!snapshot) + throw new Error( + `snapshot not found: ${document.activeSnapshotId}`, + ); + + store.dispatch({ + type: 'APPLY_SNAPSHOT', + payload: { + document: { + data: snapshot.data, + position: snapshot.position, + }, + }, + }); + } + }); +}; diff --git a/src/features/file-histoy/file-history-reducer.ts b/src/features/file-histoy/file-history-reducer.ts new file mode 100644 index 0000000..53aaf66 --- /dev/null +++ b/src/features/file-histoy/file-history-reducer.ts @@ -0,0 +1,70 @@ +import { + addSnapshot, + AddSnapshotAction, +} from 'src/features/file-histoy/reducers/add-snapshot'; +import { + UndoRedoAction, + undoRedoSnapshot, +} from 'src/features/file-histoy/reducers/undo-redo-snapshot'; +import { + selectSnapshot, + SelectSnapshotAction, +} from 'src/features/file-histoy/reducers/select-snapshot'; +import { NodePosition } from 'src/view/store/helpers/find-branch'; +import { + updateDocumentPath, + UpdateDocumentPathAction, +} from 'src/features/file-histoy/reducers/update-document-path'; +import { + deleteDocument, + DeleteDocumentAction, +} from 'src/features/file-histoy/reducers/delete-document'; + +export type Snapshot = { + data: string; + created: number; + id: string; + position: NodePosition | null; + actionType: string | null; +}; +export type FileHistory = { + snapshots: Snapshot[]; + activeSnapshotId: string | null; + state: { + canGoBack: boolean; + canGoForward: boolean; + }; +}; +export type FileHistoryState = { + documents: { + [path: string]: FileHistory; + }; +}; +export type FileHistoryAction = + | AddSnapshotAction + | UndoRedoAction + | SelectSnapshotAction + | UpdateDocumentPathAction + | DeleteDocumentAction; + +const updateState = (state: FileHistoryState, action: FileHistoryAction) => { + if (action.type === 'ADD_SNAPSHOT') { + addSnapshot(state, action); + } else if (action.type === 'SELECT_SNAPSHOT') { + selectSnapshot(state, action); + } else if (action.type === 'UNDO_REDO_SNAPSHOT') { + undoRedoSnapshot(state, action); + } else if (action.type === 'UPDATE_DOCUMENT_PATH') { + updateDocumentPath(state, action); + } else if (action.type === 'DELETE_DOCUMENT') { + deleteDocument(state, action); + } +}; + +export const fileHistoryReducer = ( + store: FileHistoryState, + action: FileHistoryAction, +): FileHistoryState => { + updateState(store, action); + return store; +}; diff --git a/src/features/file-histoy/file-history-store.ts b/src/features/file-histoy/file-history-store.ts new file mode 100644 index 0000000..468e93a --- /dev/null +++ b/src/features/file-histoy/file-history-store.ts @@ -0,0 +1,13 @@ +import { Store } from 'src/helpers/store'; +import { + FileHistoryAction, + fileHistoryReducer, + FileHistoryState, +} from 'src/features/file-histoy/file-history-reducer'; + +export const fileHistoryStore = new Store( + { + documents: {}, + }, + fileHistoryReducer, +); diff --git a/src/features/file-histoy/helpers/find-snapshot-index.ts b/src/features/file-histoy/helpers/find-snapshot-index.ts new file mode 100644 index 0000000..fa92c75 --- /dev/null +++ b/src/features/file-histoy/helpers/find-snapshot-index.ts @@ -0,0 +1,6 @@ +import { Snapshot } from 'src/features/file-histoy/file-history-reducer'; + +export const findSnapshotIndex = (snapshots: Snapshot[], id: string | null) => { + if (!id) return -1; + return snapshots.findIndex((snapshot) => snapshot.id === id); +}; diff --git a/src/features/file-histoy/helpers/update-navigation-state.ts b/src/features/file-histoy/helpers/update-navigation-state.ts new file mode 100644 index 0000000..c87bec7 --- /dev/null +++ b/src/features/file-histoy/helpers/update-navigation-state.ts @@ -0,0 +1,17 @@ +import { FileHistory } from 'src/features/file-histoy/file-history-reducer'; +import { findSnapshotIndex } from 'src/features/file-histoy/helpers/find-snapshot-index'; + +export const updateNavigationState = (document: FileHistory) => { + const activeIndex = findSnapshotIndex( + document.snapshots, + document.activeSnapshotId, + ); + if (activeIndex === -1) { + document.state.canGoBack = false; + document.state.canGoForward = false; + } else { + document.state.canGoBack = activeIndex > 0; + document.state.canGoForward = + activeIndex < document.snapshots.length - 1; + } +}; diff --git a/src/features/file-histoy/reducers/add-snapshot.ts b/src/features/file-histoy/reducers/add-snapshot.ts new file mode 100644 index 0000000..b2906d7 --- /dev/null +++ b/src/features/file-histoy/reducers/add-snapshot.ts @@ -0,0 +1,82 @@ +import { + FileHistoryState, + Snapshot, +} from 'src/features/file-histoy/file-history-reducer'; +import { id } from 'src/helpers/id'; +import { findSnapshotIndex } from 'src/features/file-histoy/helpers/find-snapshot-index'; +import { updateNavigationState } from 'src/features/file-histoy/helpers/update-navigation-state'; +import { NodePosition } from 'src/view/store/helpers/find-branch'; + +const MAX_SNAPSHOTS = 100; + +export type AddSnapshotAction = { + type: 'ADD_SNAPSHOT'; + payload: { + path: string; + data: string; + position: NodePosition | null; + actionType: string | null; + }; +}; + +export const addSnapshot = ( + state: FileHistoryState, + action: AddSnapshotAction, +) => { + if (action.payload.actionType === 'APPLY_SNAPSHOT') return; + let document = state.documents[action.payload.path]; + if (!document) { + state.documents[action.payload.path] = { + activeSnapshotId: null, + snapshots: [], + state: { + canGoBack: false, + canGoForward: false, + }, + }; + document = state.documents[action.payload.path]; + } + + const snapshots = document.snapshots; + + const activeSnapshotIndex = findSnapshotIndex( + snapshots, + document.activeSnapshotId, + ); + + if ( + activeSnapshotIndex >= 0 && + snapshots[activeSnapshotIndex].data === action.payload.data + ) { + return; + } + if (activeSnapshotIndex !== snapshots.length - 1) { + // Remove obsolete snapshots (between the active snapshot and the end) + snapshots.splice(activeSnapshotIndex + 1); + } + + if (snapshots.length >= MAX_SNAPSHOTS) { + const numSnapshotsToRemove = snapshots.length - MAX_SNAPSHOTS + 1; + snapshots.splice(0, numSnapshotsToRemove); + } + + const now = Date.now(); + // remove snapshots that less than n ms apart + if (snapshots.length > 0) { + const mostRecentSnapshot = snapshots[snapshots.length - 1]; + const timeDifference = now - mostRecentSnapshot.created; + if (timeDifference < 16) { + snapshots.pop(); + } + } + const snapshot = { + data: action.payload.data, + created: Date.now(), + id: id.snapshot(), + position: action.payload.position, + actionType: action.payload.actionType, + } as Snapshot; + snapshots.push(snapshot); + document.activeSnapshotId = snapshot.id; + updateNavigationState(document); +}; diff --git a/src/features/file-histoy/reducers/delete-document.ts b/src/features/file-histoy/reducers/delete-document.ts new file mode 100644 index 0000000..9e23caf --- /dev/null +++ b/src/features/file-histoy/reducers/delete-document.ts @@ -0,0 +1,18 @@ +import { FileHistoryState } from 'src/features/file-histoy/file-history-reducer'; + +export type DeleteDocumentAction = { + type: 'DELETE_DOCUMENT'; + payload: { + path: string; + }; +}; + +export const deleteDocument = ( + store: FileHistoryState, + action: DeleteDocumentAction, +) => { + const { path } = action.payload; + if (store.documents[path]) { + delete store.documents[path]; + } +}; diff --git a/src/features/file-histoy/reducers/select-snapshot.ts b/src/features/file-histoy/reducers/select-snapshot.ts new file mode 100644 index 0000000..6310af0 --- /dev/null +++ b/src/features/file-histoy/reducers/select-snapshot.ts @@ -0,0 +1,25 @@ +import { FileHistoryState } from 'src/features/file-histoy/file-history-reducer'; +import { updateNavigationState } from 'src/features/file-histoy/helpers/update-navigation-state'; + +export type SelectSnapshotAction = { + type: 'SELECT_SNAPSHOT'; + payload: { + path: string; + snapshotId: string; + }; +}; + +export const selectSnapshot = ( + state: FileHistoryState, + action: SelectSnapshotAction, +) => { + const document = state.documents[action.payload.path]; + + if (document) { + const snapshot = document.snapshots.find( + (s) => s.id === action.payload.snapshotId, + ); + if (snapshot) document.activeSnapshotId = snapshot.id; + updateNavigationState(document); + } +}; diff --git a/src/features/file-histoy/reducers/undo-redo-snapshot.ts b/src/features/file-histoy/reducers/undo-redo-snapshot.ts new file mode 100644 index 0000000..a42f46b --- /dev/null +++ b/src/features/file-histoy/reducers/undo-redo-snapshot.ts @@ -0,0 +1,43 @@ +import { FileHistoryState } from 'src/features/file-histoy/file-history-reducer'; +import { findSnapshotIndex } from 'src/features/file-histoy/helpers/find-snapshot-index'; +import { updateNavigationState } from 'src/features/file-histoy/helpers/update-navigation-state'; + +export type UndoRedoAction = { + type: 'UNDO_REDO_SNAPSHOT'; + payload: { + path: string; + direction: 'back' | 'forward'; + }; +}; + +export const undoRedoSnapshot = ( + state: FileHistoryState, + action: UndoRedoAction, +) => { + const document = state.documents[action.payload.path]; + + if (!document || document.snapshots.length === 0) return; + + const currentIndex = findSnapshotIndex( + document.snapshots, + document.activeSnapshotId, + ); + if (currentIndex === -1) { + throw new Error(`active snapshot not found for ${action.payload.path}`); + } + + let newIndex: number; + if (action.payload.direction === 'back') { + newIndex = currentIndex - 1; + } else { + newIndex = currentIndex + 1; + } + + if (newIndex < 0 || newIndex >= document.snapshots.length) { + return; + } + + const newSnapshot = document.snapshots[newIndex]; + document.activeSnapshotId = newSnapshot.id; + updateNavigationState(document); +}; diff --git a/src/features/file-histoy/reducers/update-document-path.ts b/src/features/file-histoy/reducers/update-document-path.ts new file mode 100644 index 0000000..5dbc893 --- /dev/null +++ b/src/features/file-histoy/reducers/update-document-path.ts @@ -0,0 +1,19 @@ +import { FileHistoryState } from 'src/features/file-histoy/file-history-reducer'; + +export type UpdateDocumentPathAction = { + type: 'UPDATE_DOCUMENT_PATH'; + payload: { + oldPath: string; + newPath: string; + }; +}; +export const updateDocumentPath = ( + store: FileHistoryState, + action: UpdateDocumentPathAction, +) => { + const document = store.documents[action.payload.oldPath]; + if (document) { + delete store.documents[action.payload.oldPath]; + store.documents[action.payload.newPath] = document; + } +}; diff --git a/src/view/actions/keyboard-shortcuts/helpers/create-commands.ts b/src/features/keyboard-shortcuts/helpers/create-commands.ts similarity index 80% rename from src/view/actions/keyboard-shortcuts/helpers/create-commands.ts rename to src/features/keyboard-shortcuts/helpers/create-commands.ts index db26439..562609d 100644 --- a/src/view/actions/keyboard-shortcuts/helpers/create-commands.ts +++ b/src/features/keyboard-shortcuts/helpers/create-commands.ts @@ -1,5 +1,6 @@ import { Hotkey, Notice } from 'obsidian'; import { DocumentStore } from 'src/view/view'; +import { fileHistoryStore } from 'src/features/file-histoy/file-history-store'; const lang = { save_changes_and_exit_card: 'Save changes and exit card', @@ -14,6 +15,8 @@ const lang = { go_down: 'Go down', go_right: 'Go right', go_left: 'Go left', + undo_change: 'Undo change', + redo_change: 'Redo change', }; export type PluginCommand = { @@ -33,6 +36,9 @@ export const createCommands = () => { const isActiveAndNotEditing = (store: DocumentStore) => { return isActive(store) && !isEditing(store); }; + const isActiveAndHasFile = (store: DocumentStore) => { + return isActive(store) && !!store.getValue().file.path; + }; return { enable_edit_mode: { check: isActive, @@ -191,5 +197,35 @@ export const createCommands = () => { { key: 'ArrowUp', modifiers: [] }, ], }, + undo_change: { + check: isActiveAndHasFile, + callback: (store) => { + const path = store.getValue().file.path; + if (path) + fileHistoryStore.dispatch({ + type: 'UNDO_REDO_SNAPSHOT', + payload: { + direction: 'back', + path, + }, + }); + }, + hotkeys: [{ key: 'Z', modifiers: ['Ctrl', 'Shift'] }], + }, + redo_change: { + check: isActiveAndHasFile, + callback: (store) => { + const path = store.getValue().file.path; + if (path) + fileHistoryStore.dispatch({ + type: 'UNDO_REDO_SNAPSHOT', + payload: { + direction: 'forward', + path, + }, + }); + }, + hotkeys: [{ key: 'Y', modifiers: ['Ctrl', 'Shift'] }], + }, } satisfies Record; }; diff --git a/src/view/actions/keyboard-shortcuts/keyboard-shortcuts.ts b/src/features/keyboard-shortcuts/keyboard-shortcuts.ts similarity index 95% rename from src/view/actions/keyboard-shortcuts/keyboard-shortcuts.ts rename to src/features/keyboard-shortcuts/keyboard-shortcuts.ts index 45a2f6a..7ff86e3 100644 --- a/src/view/actions/keyboard-shortcuts/keyboard-shortcuts.ts +++ b/src/features/keyboard-shortcuts/keyboard-shortcuts.ts @@ -2,7 +2,7 @@ import { DocumentStore } from 'src/view/view'; import { createCommands, PluginCommand, -} from 'src/view/actions/keyboard-shortcuts/helpers/create-commands'; +} from 'src/features/keyboard-shortcuts/helpers/create-commands'; import { Hotkey } from 'obsidian'; enum Modifiers { diff --git a/src/helpers/id.ts b/src/helpers/id.ts index d577b16..68a5847 100644 --- a/src/helpers/id.ts +++ b/src/helpers/id.ts @@ -5,4 +5,5 @@ export const id = { node: () => uniqid.time('n-'), column: () => uniqid.time('c-'), group: () => uniqid.time('g-'), + snapshot: () => uniqid.time('s-'), }; diff --git a/src/helpers/relative-time.ts b/src/helpers/relative-time.ts new file mode 100644 index 0000000..7200791 --- /dev/null +++ b/src/helpers/relative-time.ts @@ -0,0 +1,22 @@ +const rtf1 = new Intl.RelativeTimeFormat('en', { style: 'long' }); +export const relativeTime = (updated: number) => { + const difference = Date.now() - updated; + const days = Math.floor(difference / (1000 * 60 * 60 * 24)); + const hours = Math.floor(difference / (1000 * 60 * 60)); + const minutes = Math.floor(difference / (1000 * 60)); + const seconds = Math.floor(difference / 1000); + + let relativeTime; + if (days > 0) { + relativeTime = rtf1.format(-days, 'day'); + } else if (hours > 0) { + relativeTime = rtf1.format(-hours, 'hour'); + } else if (minutes > 0) { + relativeTime = rtf1.format(-minutes, 'minute'); + } else if (seconds >= 30) { + relativeTime = 'A moment ago'; + } else { + relativeTime = 'Just now'; + } + return relativeTime; +}; diff --git a/src/main.ts b/src/main.ts index 70aa503..3f68320 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,9 +16,11 @@ import { Settings } from 'src/settings/settings-type'; import { registerFileMenuEvent } from 'src/obsidian/events/workspace/register-file-menu-event'; import { registerFileRenameEvent } from 'src/obsidian/events/vault/register-file-move-event'; import { registerFileDeleteEvent } from 'src/obsidian/events/vault/register-file-delete-event'; +import { applyFileHistoryEffect } from 'src/features/file-histoy/effects/apply-file-history-effect'; export default class TreeEdit extends Plugin { settings: Store; + private onDestroyCallbacks: Set<() => void> = new Set(); async onload() { await this.loadSettings(); @@ -27,6 +29,7 @@ export default class TreeEdit extends Plugin { // @ts-ignore this.register(around(WorkspaceLeaf.prototype, { setViewState })); this.registerEvents(); + this.registerEffects(); } onunload() {} @@ -52,4 +55,8 @@ export default class TreeEdit extends Plugin { registerFileRenameEvent(this); registerFileDeleteEvent(this); } + + private registerEffects() { + this.onDestroyCallbacks.add(applyFileHistoryEffect()); + } } diff --git a/src/obsidian/events/vault/register-file-delete-event.ts b/src/obsidian/events/vault/register-file-delete-event.ts index aaa541a..94707fc 100644 --- a/src/obsidian/events/vault/register-file-delete-event.ts +++ b/src/obsidian/events/vault/register-file-delete-event.ts @@ -1,18 +1,28 @@ import LabeledAnnotations from '../../../main'; import { TFile } from 'obsidian'; import { fileTypeCache } from 'src/obsidian/patches/set-view-state'; +import { deletePath } from 'src/view/helpers/stores-cache'; +import { fileHistoryStore } from 'src/features/file-histoy/file-history-store'; export const registerFileDeleteEvent = (plugin: LabeledAnnotations) => { plugin.registerEvent( plugin.app.vault.on('delete', (file) => { if (file instanceof TFile) { - if (fileTypeCache[file.path]) + if (fileTypeCache[file.path]) { + deletePath(file.path); + fileHistoryStore.dispatch({ + type: 'DELETE_DOCUMENT', + payload: { + path: file.path, + }, + }); plugin.settings.dispatch({ type: 'SET_DOCUMENT_TYPE_TO_MARKDOWN', payload: { path: file.path, }, }); + } } }), ); diff --git a/src/obsidian/events/vault/register-file-move-event.ts b/src/obsidian/events/vault/register-file-move-event.ts index 23caf04..b820680 100644 --- a/src/obsidian/events/vault/register-file-move-event.ts +++ b/src/obsidian/events/vault/register-file-move-event.ts @@ -1,19 +1,30 @@ import { TFile } from 'obsidian'; import TreeEdit from 'src/main'; import { fileTypeCache } from 'src/obsidian/patches/set-view-state'; +import { updatePath } from 'src/view/helpers/stores-cache'; +import { fileHistoryStore } from 'src/features/file-histoy/file-history-store'; export const registerFileRenameEvent = (plugin: TreeEdit) => { plugin.registerEvent( plugin.app.vault.on('rename', (file, oldPath) => { if (file instanceof TFile) { - if (fileTypeCache[oldPath]) - plugin.settings.dispatch({ - type: 'SET_DOCUMENT_PATH', + if (fileTypeCache[oldPath]) { + updatePath(oldPath, file.path); + fileHistoryStore.dispatch({ + type: 'UPDATE_DOCUMENT_PATH', payload: { newPath: file.path, oldPath, }, }); + plugin.settings.dispatch({ + type: 'UPDATE_DOCUMENT_PATH', + payload: { + newPath: file.path, + oldPath, + }, + }); + } } }), ); diff --git a/src/settings/settings-reducer.ts b/src/settings/settings-reducer.ts index 7a3567f..8c8b6f3 100644 --- a/src/settings/settings-reducer.ts +++ b/src/settings/settings-reducer.ts @@ -14,7 +14,7 @@ export type SettingsActions = }; } | { - type: 'SET_DOCUMENT_PATH'; + type: 'UPDATE_DOCUMENT_PATH'; payload: { oldPath: string; newPath: string; @@ -26,7 +26,7 @@ const updateState = (store: Settings, action: SettingsActions) => { delete store.documents[action.payload.path]; } else if (action.type === 'SET_DOCUMENT_TYPE_TO_TREE') { store.documents[action.payload.path] = true; - } else if (action.type === 'SET_DOCUMENT_PATH') { + } else if (action.type === 'UPDATE_DOCUMENT_PATH') { delete store.documents[action.payload.oldPath]; store.documents[action.payload.newPath] = true; } diff --git a/src/view/actions/dnd/droppable.ts b/src/view/actions/dnd/droppable.ts index 6b02be4..0739d07 100644 --- a/src/view/actions/dnd/droppable.ts +++ b/src/view/actions/dnd/droppable.ts @@ -1,5 +1,5 @@ import { DocumentStore } from 'src/view/view'; -import { NodePosition } from 'src/view/store/document-reducer'; +import { NodeDirection } from 'src/view/store/document-reducer'; const getDropPosition = (event: DragEvent, targetElement: HTMLElement) => { const boundingBox = targetElement.getBoundingClientRect(); @@ -51,7 +51,7 @@ export const droppable = (node: HTMLElement, store: DocumentStore) => { payload: { droppedNodeId: data, targetNodeId: event.target.id, - position: getDropPosition(event, event.target) as NodePosition, + position: getDropPosition(event, event.target) as NodeDirection, }, }); } diff --git a/src/view/components/container/column/components/group/components/card/card.svelte b/src/view/components/container/column/components/group/components/card/card.svelte index ab6434f..1609e00 100644 --- a/src/view/components/container/column/components/group/components/card/card.svelte +++ b/src/view/components/container/column/components/group/components/card/card.svelte @@ -41,15 +41,20 @@ {#if editing}