feat: undo redo

This commit is contained in:
y 2024-02-24 20:43:51 +01:00
parent fafe25b0d1
commit 65b79484e4
41 changed files with 1010 additions and 125 deletions

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ data.json
.DS_Store
temp
.idea
references/*

View file

@ -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,
},
},
});
}
});
};

View 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;
};

View 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,
);

View 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);
};

View 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;
}
};

View 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);
};

View 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];
}
};

View 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);
}
};

View 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);
};

View 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;
}
};

View file

@ -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>;
};

View file

@ -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 {

View file

@ -5,4 +5,5 @@ export const id = {
node: () => uniqid.time('n-'),
column: () => uniqid.time('c-'),
group: () => uniqid.time('g-'),
snapshot: () => uniqid.time('s-'),
};

View 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;
};

View file

@ -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());
}
}

View file

@ -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,
},
});
}
}
}),
);

View file

@ -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,
},
});
}
}
}),
);

View file

@ -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;
}

View file

@ -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,
},
});
}

View file

@ -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);

View file

@ -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>

View file

@ -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>

View file

@ -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;

View 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>

View file

@ -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);
},
};
};

View file

@ -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>

View file

@ -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>

View 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;
} = {};

View file

@ -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;
}
};

View file

@ -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);
}
});

View file

@ -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,
});
}
}

View file

@ -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);
}
}
});

View file

@ -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];
};

View file

@ -18,8 +18,14 @@ export const initialDocumentState = (): DocumentState => ({
activeNodeId: '',
savePreviousNode: false,
},
ui: {
showHistorySidebar: false,
},
},
refs: {
container: null,
},
file: {
path: null,
},
});

View 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);
}
};

View file

@ -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;
};
};

View file

@ -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);
};

View file

@ -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) => {

View file

@ -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',
});
}