mirror of
https://github.com/ycnmhd/obsidian-lineage.git
synced 2026-07-22 05:12:21 +00:00
feat: undo redo
This commit is contained in:
parent
fafe25b0d1
commit
65b79484e4
41 changed files with 1010 additions and 125 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,3 +22,4 @@ data.json
|
|||
.DS_Store
|
||||
temp
|
||||
.idea
|
||||
references/*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
70
src/features/file-histoy/file-history-reducer.ts
Normal file
70
src/features/file-histoy/file-history-reducer.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
13
src/features/file-histoy/file-history-store.ts
Normal file
13
src/features/file-histoy/file-history-store.ts
Normal file
|
|
@ -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<FileHistoryState, FileHistoryAction>(
|
||||
{
|
||||
documents: {},
|
||||
},
|
||||
fileHistoryReducer,
|
||||
);
|
||||
6
src/features/file-histoy/helpers/find-snapshot-index.ts
Normal file
6
src/features/file-histoy/helpers/find-snapshot-index.ts
Normal file
|
|
@ -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);
|
||||
};
|
||||
17
src/features/file-histoy/helpers/update-navigation-state.ts
Normal file
17
src/features/file-histoy/helpers/update-navigation-state.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
82
src/features/file-histoy/reducers/add-snapshot.ts
Normal file
82
src/features/file-histoy/reducers/add-snapshot.ts
Normal file
|
|
@ -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);
|
||||
};
|
||||
18
src/features/file-histoy/reducers/delete-document.ts
Normal file
18
src/features/file-histoy/reducers/delete-document.ts
Normal file
|
|
@ -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];
|
||||
}
|
||||
};
|
||||
25
src/features/file-histoy/reducers/select-snapshot.ts
Normal file
25
src/features/file-histoy/reducers/select-snapshot.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
43
src/features/file-histoy/reducers/undo-redo-snapshot.ts
Normal file
43
src/features/file-histoy/reducers/undo-redo-snapshot.ts
Normal file
|
|
@ -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);
|
||||
};
|
||||
19
src/features/file-histoy/reducers/update-document-path.ts
Normal file
19
src/features/file-histoy/reducers/update-document-path.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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<keyof typeof lang, PluginCommand>;
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
@ -5,4 +5,5 @@ export const id = {
|
|||
node: () => uniqid.time('n-'),
|
||||
column: () => uniqid.time('c-'),
|
||||
group: () => uniqid.time('g-'),
|
||||
snapshot: () => uniqid.time('s-'),
|
||||
};
|
||||
|
|
|
|||
22
src/helpers/relative-time.ts
Normal file
22
src/helpers/relative-time.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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<Settings, SettingsActions>;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,15 +41,20 @@
|
|||
{#if editing}
|
||||
<textarea use:saveNodeContent={{ editing, store, node }} />
|
||||
{:else}
|
||||
<div class="content" use:draggable={{ id: node.id, store }}>
|
||||
<div class="drag-handle"></div>
|
||||
<div
|
||||
class="content"
|
||||
use:draggable={{ id: node.id, store }}
|
||||
>
|
||||
<div
|
||||
class="drag-handle"
|
||||
></div>
|
||||
<div
|
||||
on:dblclick={() => {
|
||||
store.dispatch({ type: 'ENABLE_EDIT_MODE' });
|
||||
}}
|
||||
>
|
||||
{#each node.content.split('\n') as line}
|
||||
<span>{line}</span><br>
|
||||
<span>{line}</span><br />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -72,7 +77,7 @@
|
|||
position="bottom"
|
||||
></CreateCardButton>
|
||||
{/if}
|
||||
<EditNodeButton nodeId={node.id} {editing} />
|
||||
<EditNodeButton {editing} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -149,6 +154,9 @@
|
|||
transparent 50%
|
||||
);
|
||||
}
|
||||
.dnd-disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
:root {
|
||||
--border: 10px #5acf5a solid;
|
||||
--border-shadow-top: 0 -5px 15px -5px rgba(90, 207, 90, 0.79);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { NodePosition } from 'src/view/store/document-reducer';
|
||||
import { NodeDirection } from 'src/view/store/document-reducer';
|
||||
import { getStore } from 'src/view/components/container/get-store';
|
||||
import FloatingButton from './floating-button.svelte';
|
||||
import { PlusIcon } from 'lucide-svelte';
|
||||
|
||||
export let position: NodePosition;
|
||||
export let nodeId: string;
|
||||
export let parentId: string;
|
||||
export let position: NodeDirection;
|
||||
const store = getStore();
|
||||
const createCard = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
store.dispatch({
|
||||
type: 'CREATE_NODE',
|
||||
payload: { nodeId: nodeId, parentId, position },
|
||||
payload: { position },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@
|
|||
top: 'position-top',
|
||||
right: 'position-right',
|
||||
bottom: 'position-bottom',
|
||||
'bottom-right': 'position-bottom-right',
|
||||
'bottom-right': 'position-bottom-right',
|
||||
'top-right': 'position-top-right',
|
||||
};
|
||||
</script>
|
||||
|
||||
<button class={classNames(classes, positionClasses[position])} on:click>
|
||||
<button
|
||||
class={classNames(classes, positionClasses[position])}
|
||||
on:click
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
|
|
@ -24,17 +27,20 @@
|
|||
--floating-button-bg: #dbdbdb;
|
||||
}
|
||||
button {
|
||||
color: black;
|
||||
color: black;
|
||||
width: var(--floating-button-width);
|
||||
height: var(--floating-button-height);
|
||||
position: absolute;
|
||||
opacity: 0.3;
|
||||
background-color: var(--floating-button-bg);
|
||||
transition: opacity 200ms;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
.is-disabled{
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button:not(.is-disabled):hover {
|
||||
opacity: 8;
|
||||
}
|
||||
|
||||
|
|
@ -58,21 +64,20 @@
|
|||
right: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
}
|
||||
|
||||
.position-bottom-right {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.position-top-right {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@
|
|||
import Column from './column/column.svelte';
|
||||
import { DocumentStore } from 'src/view/view';
|
||||
import { onDestroy, onMount, setContext } from 'svelte';
|
||||
import { keyboardShortcuts } from 'src/view/actions/keyboard-shortcuts/keyboard-shortcuts';
|
||||
import { keyboardShortcuts } from 'src/features/keyboard-shortcuts/keyboard-shortcuts';
|
||||
import FileHistory from './file-history/file-histoy.svelte';
|
||||
import { fileHistoryStore } from 'src/features/file-histoy/file-history-store';
|
||||
import ControlsBar from './controls-bar/controls-bar.svelte';
|
||||
|
||||
export let store: DocumentStore;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
let ref: HTMLElement;
|
||||
onMount(() => {
|
||||
store.dispatch({ type: 'SET_CONTAINER', payload: { ref } });
|
||||
|
|
@ -16,17 +20,30 @@
|
|||
setContext('store', store);
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class="container"
|
||||
id="columns-container"
|
||||
use:keyboardShortcuts={store}
|
||||
>
|
||||
<div class="columns">
|
||||
{#each $store.columns as column (column.id)}
|
||||
<Column {column} />
|
||||
{/each}
|
||||
<div class="main">
|
||||
<ControlsBar
|
||||
fileHistory={$fileHistoryStore.documents[$store.file.path]}
|
||||
path={$store.file.path}
|
||||
/>
|
||||
<div
|
||||
bind:this={ref}
|
||||
class="container"
|
||||
id="columns-container"
|
||||
tabindex="0"
|
||||
use:keyboardShortcuts={store}
|
||||
>
|
||||
<div class="columns">
|
||||
{#each $store.columns as column (column.id)}
|
||||
<Column {column} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if $store.state.ui.showHistorySidebar && $store.file.path && $fileHistoryStore.documents[$store.file.path]}
|
||||
<FileHistory
|
||||
fileHistory={$fileHistoryStore.documents[$store.file.path]}
|
||||
path={$store.file.path}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -35,15 +52,24 @@
|
|||
--node-bg-active: #fff;
|
||||
--parent-bg: #68a6ca;
|
||||
--container-bg: #1d7db4;
|
||||
--sidebar-right: 50px;
|
||||
}
|
||||
.main {
|
||||
background-color: var(--container-bg);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding-right: 50px;
|
||||
}
|
||||
.container {
|
||||
background-color: var(--container-bg);
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
padding: 100px;
|
||||
padding-left: 100px;
|
||||
position: relative;
|
||||
}
|
||||
.columns {
|
||||
display: flex;
|
||||
|
|
|
|||
119
src/view/components/container/controls-bar/controls-bar.svelte
Normal file
119
src/view/components/container/controls-bar/controls-bar.svelte
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<script lang="ts">
|
||||
import { HelpCircle, HistoryIcon, RedoIcon, UndoIcon } from 'lucide-svelte';
|
||||
import { getStore } from 'src/view/components/container/get-store';
|
||||
import { fileHistoryStore } from 'src/features/file-histoy/file-history-store';
|
||||
import { FileHistory } from 'src/features/file-histoy/file-history-reducer';
|
||||
|
||||
const store = getStore();
|
||||
export let fileHistory: FileHistory | null;
|
||||
export let path: string | null;
|
||||
|
||||
const handleNextClick = () => {
|
||||
if (path)
|
||||
fileHistoryStore.dispatch({
|
||||
type: 'UNDO_REDO_SNAPSHOT',
|
||||
payload: {
|
||||
path,
|
||||
direction: 'forward',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviousClick = () => {
|
||||
if (path)
|
||||
fileHistoryStore.dispatch({
|
||||
type: 'UNDO_REDO_SNAPSHOT',
|
||||
payload: {
|
||||
path,
|
||||
direction: 'back',
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="canvas-controls">
|
||||
<div class="canvas-control-group">
|
||||
<button
|
||||
aria-label="History"
|
||||
class="canvas-control-item"
|
||||
data-tooltip-position="left"
|
||||
disabled={!path || !$fileHistoryStore.documents[path]}
|
||||
on:click={() => {
|
||||
store.dispatch({ type: 'UI/TOGGLE_HISTORY_SIDEBAR' });
|
||||
}}
|
||||
>
|
||||
<HistoryIcon class="svg-icon" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label="Undo"
|
||||
class="canvas-control-item"
|
||||
data-tooltip-position="left"
|
||||
disabled={!fileHistory || !fileHistory.state.canGoBack}
|
||||
on:click={handlePreviousClick}
|
||||
>
|
||||
<UndoIcon class="svg-icon" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Redo"
|
||||
class="canvas-control-item"
|
||||
data-tooltip-position="left"
|
||||
disabled={!fileHistory || !fileHistory.state.canGoForward}
|
||||
on:click={handleNextClick}
|
||||
>
|
||||
<RedoIcon class="svg-icon" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="canvas-control-group">
|
||||
<div
|
||||
aria-label="Help"
|
||||
class="canvas-control-item"
|
||||
data-tooltip-position="left"
|
||||
>
|
||||
<HelpCircle class="svg-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.canvas-controls {
|
||||
right: var(--size-4-2);
|
||||
top: var(--size-4-2);
|
||||
gap: var(--size-4-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
}
|
||||
.canvas-control-group {
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
box-shadow: var(--input-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.canvas-control-item {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
height: auto;
|
||||
display: flex;
|
||||
line-height: 1;
|
||||
font-size: inherit;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--size-4-2);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
color: var(--text-muted);
|
||||
background-color: var(--interactive-normal);
|
||||
--icon-size: var(--icon-s);
|
||||
--icon-stroke: var(--icon-s-stroke-width);
|
||||
cursor: pointer;
|
||||
}
|
||||
.canvas-control-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { relativeTime } from 'src/helpers/relative-time';
|
||||
|
||||
export const updateRelativeTime = (element: HTMLElement) => {
|
||||
const interval = setInterval(() => {
|
||||
const children = Array.from(
|
||||
element.querySelectorAll('[data-created]'),
|
||||
) as HTMLElement[];
|
||||
for (const child of children) {
|
||||
const created = child.dataset['created'];
|
||||
if (created && !isNaN(+created))
|
||||
child.textContent = relativeTime(+created);
|
||||
}
|
||||
}, 30 * 1000);
|
||||
return {
|
||||
destroy: () => {
|
||||
clearInterval(interval);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts">
|
||||
import { Snapshot } from 'src/features/file-histoy/file-history-reducer';
|
||||
import { fileHistoryStore } from 'src/features/file-histoy/file-history-store';
|
||||
import { relativeTime } from 'src/helpers/relative-time';
|
||||
|
||||
export let snapshot: Snapshot;
|
||||
export let active: boolean;
|
||||
export let filePath: string;
|
||||
const actionTypeStrings: Record<string, string> = {
|
||||
SET_NODE_CONTENT: 'Updated a node',
|
||||
CREATE_FIRST_NODE: 'Created a node',
|
||||
CREATE_NODE: 'Created a node',
|
||||
DROP_NODE: 'Moved a node',
|
||||
APPLY_SNAPSHOT: 'Applied a snapshot',
|
||||
INITIAL_DOCUMENT: 'Initial document',
|
||||
};
|
||||
const label = snapshot.actionType
|
||||
? actionTypeStrings[snapshot.actionType] || snapshot.actionType
|
||||
: 'snapshot';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="snapshot"
|
||||
class:selected={active}
|
||||
on:click={() =>
|
||||
fileHistoryStore.dispatch({
|
||||
type: 'SELECT_SNAPSHOT',
|
||||
payload: { snapshotId: snapshot.id, path: filePath },
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{label}
|
||||
</div>
|
||||
<div class="time" data-created={snapshot.created}>
|
||||
{relativeTime(snapshot.created)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.snapshot {
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.label {
|
||||
font-size: 16px;
|
||||
color: var(--color-base-60);
|
||||
}
|
||||
.selected {
|
||||
background-color: var(--nav-item-background-selected);
|
||||
}
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: var(--color-base-40);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { FileHistory } from 'src/features/file-histoy/file-history-reducer';
|
||||
import SnapshotButton from './components/snapshot-button.svelte';
|
||||
import { updateRelativeTime } from 'src/view/components/container/file-history/actions/update-relative-time';
|
||||
|
||||
export let fileHistory: FileHistory;
|
||||
export let path: string;
|
||||
</script>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="snapshots-list" use:updateRelativeTime>
|
||||
{#each [...fileHistory.snapshots].sort((a, b) => b.created - a.created) as snapshot (snapshot.id)}
|
||||
<SnapshotButton
|
||||
{snapshot}
|
||||
active={fileHistory.activeSnapshotId === snapshot.id}
|
||||
filePath={path}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: fit-content;
|
||||
background-color: var(--background-secondary);
|
||||
position: absolute;
|
||||
right: var(--sidebar-right);
|
||||
top: var(--size-4-2);
|
||||
padding: var(--size-4-2);
|
||||
}
|
||||
|
||||
.snapshots-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
30
src/view/helpers/stores-cache.ts
Normal file
30
src/view/helpers/stores-cache.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { DocumentStore } from 'src/view/view';
|
||||
|
||||
export const deletePath = (oldPath: string) => {
|
||||
if (oldPath in stores) {
|
||||
const oldEntry = stores[oldPath];
|
||||
delete stores[oldPath];
|
||||
oldEntry.dispatch({
|
||||
type: 'FS/SET_FILE_PATH',
|
||||
payload: {
|
||||
path: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
export const updatePath = (oldPath: string, newPath: string) => {
|
||||
if (oldPath in stores) {
|
||||
const oldEntry = stores[oldPath];
|
||||
delete stores[oldPath];
|
||||
stores[newPath] = oldEntry;
|
||||
oldEntry.dispatch({
|
||||
type: 'FS/SET_FILE_PATH',
|
||||
payload: {
|
||||
path: newPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
export const stores: {
|
||||
[path: string]: DocumentStore;
|
||||
} = {};
|
||||
|
|
@ -30,9 +30,11 @@ import {
|
|||
disableEditMode,
|
||||
DisableEditModeAction,
|
||||
} from 'src/view/store/reducers/editing/disable-edit-mode';
|
||||
import { findNode } from 'src/view/store/helpers/find-node';
|
||||
import { findChildGroup } from 'src/view/store/helpers/find-branch';
|
||||
import { findNodeColumn } from 'src/view/store/helpers/find-node-column';
|
||||
import { NodePosition } from 'src/view/store/helpers/find-branch';
|
||||
import {
|
||||
changeActiveNode,
|
||||
ChangeActiveNodeAction,
|
||||
} from 'src/view/store/reducers/change-active-node';
|
||||
|
||||
export type ColumnNode = {
|
||||
id: string;
|
||||
|
|
@ -67,26 +69,30 @@ export type DocumentState = {
|
|||
activeNodeId: string;
|
||||
savePreviousNode: boolean;
|
||||
};
|
||||
ui: {
|
||||
showHistorySidebar: boolean;
|
||||
};
|
||||
};
|
||||
refs: {
|
||||
container: HTMLElement | null;
|
||||
};
|
||||
file: {
|
||||
path: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type SiblingPosition = 'top' | 'bottom';
|
||||
export type NodePosition = SiblingPosition | 'right';
|
||||
export type NodeDirection = SiblingPosition | 'right';
|
||||
|
||||
export type SavedDocument = string;
|
||||
export type SavedDocument = {
|
||||
data: string;
|
||||
position: NodePosition | null;
|
||||
};
|
||||
|
||||
export type DocumentAction =
|
||||
| LoadDocumentAction
|
||||
| CreateNodeAction
|
||||
| {
|
||||
type: 'CHANGE_ACTIVE_NODE';
|
||||
payload: {
|
||||
direction: NodePosition | 'left';
|
||||
};
|
||||
}
|
||||
| ChangeActiveNodeAction
|
||||
| {
|
||||
type: 'SET_ACTIVE_NODE';
|
||||
payload: {
|
||||
|
|
@ -108,6 +114,15 @@ export type DocumentAction =
|
|||
| {
|
||||
type: 'SET_CONTAINER';
|
||||
payload: { ref: HTMLElement | null };
|
||||
}
|
||||
| {
|
||||
type: 'FS/SET_FILE_PATH';
|
||||
payload: {
|
||||
path: string | null;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'UI/TOGGLE_HISTORY_SIDEBAR';
|
||||
};
|
||||
const updateState = (state: DocumentState, action: DocumentAction) => {
|
||||
const columns = state.columns;
|
||||
|
|
@ -115,40 +130,7 @@ const updateState = (state: DocumentState, action: DocumentAction) => {
|
|||
if (action.type === 'SET_ACTIVE_NODE') {
|
||||
updateActiveNode(state, action.payload.id);
|
||||
} else if (action.type === 'CHANGE_ACTIVE_NODE') {
|
||||
const node = findNode(state.columns, state.state.activeBranch.node);
|
||||
if (!node) return;
|
||||
const columnIndex = findNodeColumn(state.columns, node.parentId);
|
||||
const column = columns[columnIndex];
|
||||
if (!column) return;
|
||||
let nextNode: ColumnNode | undefined = undefined;
|
||||
if (action.payload.direction === 'left') {
|
||||
nextNode = findNode(columns, node.parentId);
|
||||
} else if (action.payload.direction === 'right') {
|
||||
const group = findChildGroup(columns, node);
|
||||
if (group) {
|
||||
nextNode = group.nodes[0];
|
||||
} else {
|
||||
const nextColumn = columns[columnIndex + 1];
|
||||
if (!nextColumn) return;
|
||||
nextNode = nextColumn.groups[0]?.nodes?.[0];
|
||||
}
|
||||
} else {
|
||||
const allNodes = column.groups.map((g) => g.nodes).flat();
|
||||
const nodeIndex = allNodes.findIndex((n) => n.id === node.id);
|
||||
|
||||
if (action.payload.direction === 'top') {
|
||||
if (nodeIndex > 0) {
|
||||
nextNode = allNodes[nodeIndex - 1];
|
||||
}
|
||||
} else if (action.payload.direction === 'bottom') {
|
||||
if (nodeIndex < allNodes.length - 1) {
|
||||
nextNode = allNodes[nodeIndex + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nextNode) {
|
||||
updateActiveNode(state, nextNode.id);
|
||||
}
|
||||
changeActiveNode(state, action);
|
||||
}
|
||||
// editing actions
|
||||
else if (action.type === 'ENABLE_EDIT_MODE') {
|
||||
|
|
@ -170,8 +152,8 @@ const updateState = (state: DocumentState, action: DocumentAction) => {
|
|||
updateActiveNode(state, action.payload.droppedNodeId);
|
||||
onDragEnd(state);
|
||||
}
|
||||
// life cycle
|
||||
else if (action.type === 'LOAD_DATA') {
|
||||
// life cycle and other
|
||||
else if (action.type === 'LOAD_DATA' || action.type === 'APPLY_SNAPSHOT') {
|
||||
loadDocument(state, action);
|
||||
} else if (action.type === 'CREATE_FIRST_NODE') {
|
||||
createFirstNode(state);
|
||||
|
|
@ -179,6 +161,10 @@ const updateState = (state: DocumentState, action: DocumentAction) => {
|
|||
resetDocument(state);
|
||||
} else if (action.type === 'SET_CONTAINER') {
|
||||
state.refs.container = action.payload.ref;
|
||||
} else if (action.type === 'FS/SET_FILE_PATH') {
|
||||
state.file.path = action.payload.path;
|
||||
} else if (action.type === 'UI/TOGGLE_HISTORY_SIDEBAR') {
|
||||
state.state.ui.showHistorySidebar = !state.state.ui.showHistorySidebar;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@ import { findNode } from 'src/view/store/helpers/find-node';
|
|||
import { alignElement } from 'src/view/store/effects/helpers/align-element';
|
||||
import { DocumentStore } from 'src/view/view';
|
||||
|
||||
const alignBranch = (store: DocumentState) => {
|
||||
const alignBranch = (store: DocumentState, behavior?: ScrollBehavior) => {
|
||||
if (!store.refs.container) return;
|
||||
const node = findNode(store.columns, store.state.activeBranch.node);
|
||||
if (node) {
|
||||
alignElement(store.refs.container, node.id);
|
||||
alignElement(store.refs.container, node.id, behavior);
|
||||
for (const id of store.state.activeBranch.parentNodes) {
|
||||
alignElement(store.refs.container, id);
|
||||
alignElement(store.refs.container, id, behavior);
|
||||
}
|
||||
for (const id of store.state.activeBranch.childGroups) {
|
||||
alignElement(store.refs.container, id);
|
||||
alignElement(store.refs.container, id, behavior);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -31,11 +31,15 @@ export const alignBranchEffect = (store: DocumentStore) => {
|
|||
action.type === 'CREATE_FIRST_NODE' ||
|
||||
action.type === 'LOAD_DATA' ||
|
||||
action.type === 'DROP_NODE' ||
|
||||
action.type === 'CHANGE_ACTIVE_NODE'
|
||||
action.type === 'CHANGE_ACTIVE_NODE' ||
|
||||
action.type === 'APPLY_SNAPSHOT'
|
||||
) {
|
||||
if (timeoutRef.align) clearTimeout(timeoutRef.align);
|
||||
timeoutRef.align = setTimeout(() => {
|
||||
alignBranch(store);
|
||||
alignBranch(
|
||||
store,
|
||||
action.type === 'APPLY_SNAPSHOT' ? 'instant' : undefined,
|
||||
);
|
||||
}, 32);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { logger } from 'src/helpers/logger';
|
||||
|
||||
export const alignElement = (container: HTMLElement, id: string) => {
|
||||
export const alignElement = (
|
||||
container: HTMLElement,
|
||||
id: string,
|
||||
behavior: ScrollBehavior = 'smooth',
|
||||
) => {
|
||||
if (!container) return;
|
||||
const element = container.querySelector('#' + id);
|
||||
if (!element) {
|
||||
|
|
@ -15,7 +19,7 @@ export const alignElement = (container: HTMLElement, id: string) => {
|
|||
const scrollTop = middle - cardRect.top;
|
||||
column.scrollBy({
|
||||
top: scrollTop * -1,
|
||||
behavior: 'smooth',
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { DocumentStore } from 'src/view/view';
|
|||
|
||||
export const saveDocumentEffect = (
|
||||
store: DocumentStore,
|
||||
save: () => Promise<void>,
|
||||
save: (actionType: string) => Promise<void>,
|
||||
) => {
|
||||
return store.subscribe(async (state, action) => {
|
||||
if (action) {
|
||||
|
|
@ -10,9 +10,10 @@ export const saveDocumentEffect = (
|
|||
action.type === 'SET_NODE_CONTENT' ||
|
||||
action.type === 'CREATE_FIRST_NODE' ||
|
||||
action.type === 'CREATE_NODE' ||
|
||||
action.type === 'DROP_NODE'
|
||||
action.type === 'DROP_NODE' ||
|
||||
action.type === 'APPLY_SNAPSHOT'
|
||||
) {
|
||||
await save();
|
||||
await save(action.type);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { ColumnNode, Columns } from 'src/view/store/document-reducer';
|
||||
import {
|
||||
ColumnNode,
|
||||
Columns,
|
||||
NodeGroup,
|
||||
} from 'src/view/store/document-reducer';
|
||||
import { findNode } from 'src/view/store/helpers/find-node';
|
||||
|
||||
export type StringSet = Set<string>;
|
||||
|
|
@ -73,3 +77,57 @@ export const findChildGroup = (
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
export type NodePosition = {
|
||||
columnIndex: number;
|
||||
groupIndex: number;
|
||||
nodeIndex: number;
|
||||
};
|
||||
|
||||
export const findNodePosition = (
|
||||
columns: Columns,
|
||||
node: ColumnNode,
|
||||
): NodePosition | null => {
|
||||
for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
|
||||
const column = columns[columnIndex];
|
||||
for (
|
||||
let groupIndex = 0;
|
||||
groupIndex < column.groups.length;
|
||||
groupIndex++
|
||||
) {
|
||||
const group = column.groups[groupIndex] as NodeGroup;
|
||||
const nodeIndex = group.nodes.findIndex((n) => n.id === node.id);
|
||||
if (nodeIndex !== -1) {
|
||||
return {
|
||||
columnIndex,
|
||||
groupIndex,
|
||||
nodeIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findNodeAtPosition = (
|
||||
columns: Columns,
|
||||
position: NodePosition,
|
||||
): ColumnNode | null => {
|
||||
const { columnIndex, groupIndex, nodeIndex } = position;
|
||||
|
||||
if (columnIndex < 0 || columnIndex >= columns.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const column = columns[columnIndex];
|
||||
if (groupIndex < 0 || groupIndex >= column.groups.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = column.groups[groupIndex] as NodeGroup;
|
||||
if (nodeIndex < 0 || nodeIndex >= group.nodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return group.nodes[nodeIndex];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,8 +18,14 @@ export const initialDocumentState = (): DocumentState => ({
|
|||
activeNodeId: '',
|
||||
savePreviousNode: false,
|
||||
},
|
||||
ui: {
|
||||
showHistorySidebar: false,
|
||||
},
|
||||
},
|
||||
refs: {
|
||||
container: null,
|
||||
},
|
||||
file: {
|
||||
path: null,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
56
src/view/store/reducers/change-active-node.ts
Normal file
56
src/view/store/reducers/change-active-node.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { findNode } from 'src/view/store/helpers/find-node';
|
||||
import { findNodeColumn } from 'src/view/store/helpers/find-node-column';
|
||||
import { findChildGroup } from 'src/view/store/helpers/find-branch';
|
||||
import { updateActiveNode } from 'src/view/store/helpers/update-active-node';
|
||||
import {
|
||||
ColumnNode,
|
||||
DocumentState,
|
||||
NodeDirection,
|
||||
} from 'src/view/store/document-reducer';
|
||||
|
||||
export type ChangeActiveNodeAction = {
|
||||
type: 'CHANGE_ACTIVE_NODE';
|
||||
payload: {
|
||||
direction: NodeDirection | 'left';
|
||||
};
|
||||
};
|
||||
export const changeActiveNode = (
|
||||
state: DocumentState,
|
||||
action: ChangeActiveNodeAction,
|
||||
) => {
|
||||
const columns = state.columns;
|
||||
const node = findNode(columns, state.state.activeBranch.node);
|
||||
if (!node) return;
|
||||
const columnIndex = findNodeColumn(columns, node.parentId);
|
||||
const column = columns[columnIndex];
|
||||
if (!column) return;
|
||||
let nextNode: ColumnNode | undefined = undefined;
|
||||
if (action.payload.direction === 'left') {
|
||||
nextNode = findNode(columns, node.parentId);
|
||||
} else if (action.payload.direction === 'right') {
|
||||
const group = findChildGroup(columns, node);
|
||||
if (group) {
|
||||
nextNode = group.nodes[0];
|
||||
} else {
|
||||
const nextColumn = columns[columnIndex + 1];
|
||||
if (!nextColumn) return;
|
||||
nextNode = nextColumn.groups[0]?.nodes?.[0];
|
||||
}
|
||||
} else {
|
||||
const allNodes = column.groups.map((g) => g.nodes).flat();
|
||||
const nodeIndex = allNodes.findIndex((n) => n.id === node.id);
|
||||
|
||||
if (action.payload.direction === 'top') {
|
||||
if (nodeIndex > 0) {
|
||||
nextNode = allNodes[nodeIndex - 1];
|
||||
}
|
||||
} else if (action.payload.direction === 'bottom') {
|
||||
if (nodeIndex < allNodes.length - 1) {
|
||||
nextNode = allNodes[nodeIndex + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nextNode) {
|
||||
updateActiveNode(state, nextNode.id);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { insertChild } from 'src/view/store/helpers/insert-child';
|
||||
import { findNodeColumn } from 'src/view/store/helpers/find-node-column';
|
||||
import { createNode } from 'src/view/store/helpers/create-node';
|
||||
import { DocumentState, NodePosition } from 'src/view/store/document-reducer';
|
||||
import { DocumentState, NodeDirection } from 'src/view/store/document-reducer';
|
||||
import { updateActiveNode } from 'src/view/store/helpers/update-active-node';
|
||||
import { findNode } from 'src/view/store/helpers/find-node';
|
||||
|
||||
export type CreateNodeAction = {
|
||||
type: 'CREATE_NODE';
|
||||
payload: {
|
||||
position: NodePosition;
|
||||
position: NodeDirection;
|
||||
__newNodeID__?: string;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +1,32 @@
|
|||
import { jsonTreeToColumns } from 'src/view/store/helpers/conversion/json-to-columns/json-tree-to-columns';
|
||||
import { markdownToJson } from 'src/view/store/helpers/conversion/markdown-to-json/markdown-to-json';
|
||||
import { updateActiveNode } from 'src/view/store/helpers/update-active-node';
|
||||
import { DocumentState, SavedDocument } from 'src/view/store/document-reducer';
|
||||
import {
|
||||
ColumnNode,
|
||||
DocumentState,
|
||||
SavedDocument,
|
||||
} from 'src/view/store/document-reducer';
|
||||
import { findNodeAtPosition } from 'src/view/store/helpers/find-branch';
|
||||
|
||||
export type LoadDocumentAction = {
|
||||
type: 'LOAD_DATA';
|
||||
type: 'LOAD_DATA' | 'APPLY_SNAPSHOT';
|
||||
payload: {
|
||||
data: SavedDocument;
|
||||
document: SavedDocument;
|
||||
};
|
||||
};
|
||||
export const loadDocument = (
|
||||
state: DocumentState,
|
||||
action: LoadDocumentAction,
|
||||
) => {
|
||||
state.columns = jsonTreeToColumns(markdownToJson(action.payload.data));
|
||||
const firstNode = state.columns[0]?.groups?.[0]?.nodes?.[0];
|
||||
if (firstNode) updateActiveNode(state, firstNode.id);
|
||||
state.columns = jsonTreeToColumns(
|
||||
markdownToJson(action.payload.document.data),
|
||||
);
|
||||
let activeNode: ColumnNode | null;
|
||||
if (action.payload.document.position) {
|
||||
activeNode = findNodeAtPosition(
|
||||
state.columns,
|
||||
action.payload.document.position,
|
||||
);
|
||||
} else activeNode = state.columns[0]?.groups?.[0]?.nodes?.[0];
|
||||
if (activeNode) updateActiveNode(state, activeNode.id);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { findNode } from 'src/view/store/helpers/find-node';
|
||||
import { findGroup } from 'src/view/store/helpers/find-branch';
|
||||
import { Columns, NodePosition } from 'src/view/store/document-reducer';
|
||||
import { Columns, NodeDirection } from 'src/view/store/document-reducer';
|
||||
import { moveNodeAsSibling } from 'src/view/store/reducers/move-node/helpers/move-node-as-sibling';
|
||||
|
||||
import { moveNodeAsChild } from 'src/view/store/reducers/move-node/helpers/move-node-as-child';
|
||||
|
|
@ -11,7 +11,7 @@ export type DropAction = {
|
|||
payload: {
|
||||
droppedNodeId: string;
|
||||
targetNodeId: string;
|
||||
position: NodePosition;
|
||||
position: NodeDirection;
|
||||
};
|
||||
};
|
||||
export const moveNode = (columns: Columns, action: DropAction) => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
DocumentAction,
|
||||
documentReducer,
|
||||
DocumentState,
|
||||
SavedDocument,
|
||||
} from 'src/view/store/document-reducer';
|
||||
import { alignBranchEffect } from 'src/view/store/effects/align-branch-effect';
|
||||
import { Unsubscriber } from 'svelte/store';
|
||||
|
|
@ -16,6 +15,10 @@ import { jsonToMarkdown } from 'src/view/store/helpers/conversion/json-to-makdow
|
|||
import { Store } from 'src/helpers/store';
|
||||
import { initialDocumentState } from 'src/view/store/helpers/initial-document-state';
|
||||
import { bringFocusToContainer } from 'src/view/store/effects/bring-focus-to-container';
|
||||
import { fileHistoryStore } from 'src/features/file-histoy/file-history-store';
|
||||
import { findNode } from 'src/view/store/helpers/find-node';
|
||||
import { findNodePosition } from 'src/view/store/helpers/find-branch';
|
||||
import { stores } from 'src/view/helpers/stores-cache';
|
||||
|
||||
export const TREE_VIEW_TYPE = 'tree';
|
||||
|
||||
|
|
@ -56,7 +59,7 @@ export class TreeView extends TextFileView {
|
|||
}
|
||||
|
||||
getDisplayText() {
|
||||
return 'Example view';
|
||||
return 'Ash' + (this.file ? ' - ' + this.file.basename : '');
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
|
|
@ -78,25 +81,58 @@ export class TreeView extends TextFileView {
|
|||
}
|
||||
}
|
||||
|
||||
private saveState = async () => {
|
||||
private requestSaveWrapper = async (actionType?: string) => {
|
||||
const store = this.store.getValue();
|
||||
const data: SavedDocument = jsonToMarkdown(
|
||||
columnsToJsonTree(store.columns),
|
||||
);
|
||||
this.setViewData(data, false);
|
||||
this.requestSave();
|
||||
const data: string = jsonToMarkdown(columnsToJsonTree(store.columns));
|
||||
if (data !== this.data) {
|
||||
const path = this.file?.path;
|
||||
if (!path) throw new Error('view does not have a file');
|
||||
const node = findNode(store.columns, store.state.activeBranch.node);
|
||||
|
||||
fileHistoryStore.dispatch({
|
||||
type: 'ADD_SNAPSHOT',
|
||||
payload: {
|
||||
data: data,
|
||||
path,
|
||||
position: node
|
||||
? findNodePosition(store.columns, node)
|
||||
: null,
|
||||
actionType: actionType ? actionType : null,
|
||||
},
|
||||
});
|
||||
this.setViewData(data, false);
|
||||
this.requestSave();
|
||||
}
|
||||
};
|
||||
private loadInitialData = () => {
|
||||
if (!this.file) {
|
||||
throw new Error('view does not have a file');
|
||||
}
|
||||
stores[this.file.path] = this.store;
|
||||
this.store.dispatch({
|
||||
type: 'FS/SET_FILE_PATH',
|
||||
payload: {
|
||||
path: this.file.path,
|
||||
},
|
||||
});
|
||||
fileHistoryStore.dispatch({
|
||||
type: 'ADD_SNAPSHOT',
|
||||
payload: {
|
||||
data: this.data,
|
||||
path: this.file.path,
|
||||
position: null,
|
||||
actionType: 'INITIAL_DOCUMENT',
|
||||
},
|
||||
});
|
||||
this.onDestroyCallbacks.add(alignBranchEffect(this.store));
|
||||
this.onDestroyCallbacks.add(
|
||||
saveDocumentEffect(this.store, this.saveState),
|
||||
saveDocumentEffect(this.store, this.requestSaveWrapper),
|
||||
);
|
||||
this.onDestroyCallbacks.add(bringFocusToContainer(this.store));
|
||||
if (!this.data) this.store.dispatch({ type: 'CREATE_FIRST_NODE' });
|
||||
else {
|
||||
const state = this.data as SavedDocument;
|
||||
this.store.dispatch({
|
||||
payload: { data: state },
|
||||
payload: { document: { data: this.data, position: null } },
|
||||
type: 'LOAD_DATA',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue