svecosystem / runed

Magical utilities for your Svelte applications (WIP)
https://runed.dev
MIT License
496 stars 24 forks source link

Feature Request: useBranchedStateHistory #44

Open petermakeswebsites opened 5 months ago

petermakeswebsites commented 5 months ago

Describe the feature in detail (code, mocks, or screenshots encouraged)

For a Svelte 5 app I made, I created a branched state history, not dissimilar to Git branches. I'd be happy to create an abstraction of this and add it to this lib. The idea is essentially as follows:

Is this something you would all find useful?

What type of pull request would this be?

New Feature

Provide relevant links or additional information.

No response

petermakeswebsites commented 5 months ago

I haven't actually tested this, so I'm not 100% it will work. But this is my alpha.

I will say, I noticed it doesn't really fit the pattern of the other history though, and I'm curious why the team chose to make some design decisions.

I like to stay as close to the primitives (runes) as possible. Using abstractions like box() and watch(), although useful, I think add an unnecessary extra layer of complexity on top. But maybe I don't fully understand their value. I wonder why you are using these in higher level functions?

I also use classes instead of functions for two reasons. First, it's more readable. Second, the methods are in the prototype so they are not re-instantiated every time a new one is created. Reduce reuse recycle ♻️!

Usage

You can create a BranchedHistory as follows:

// Create state
const history = useBranchedStateHistory("original state")

// Change state
history.state = "some new state"

// Undo
history.undo() // history.state == "original state"

// Create new state
history.state = "a new branch!"

// Undo again
history.undo() // history.state == "original state"

// Get children
children = [...history.children] // [BranchNode {value: "some new state"}, BranchNode {value: "a new branch!"} ]

// Redo
history.redo() // Remembers the last "undid" branch, ergo history.state == "a new branch!"

// Go to specific node
history.goto(children[0]) // history.state == "original state"

Proposed source

import { Set } from "svelte/reactivity";

/**
 * Represents a specific state in the timeline
 */
class BranchNode<T> {
    /**
     * This is set to the node that we called undo from, so we know where to
     * re-do. This should be reactive because it may change.
     */
    public redoTarget = $state<BranchNode<T>>();

    /**
     * Children may change, so we're using Svelte's built-in reactivity set
     */
    public readonly children = new Set<BranchNode<T>>();

    constructor(
        public readonly value: T,
        /**
         * parent will never change, and therefor does not need to be stateful
         */
        public readonly parent: BranchNode<T> | undefined = undefined
    ) {}
}

class BranchedHistory<T> {
    public readonly root: BranchNode<T>;
    public node = $state<BranchNode<T>>() as BranchNode<T>;
    constructor(def: T) {
        this.root = new BranchNode(def);
        this.node = this.root;
    }

    public get state() {
        return this.node!.value;
    }

    public set state(v: T) {
        const parentNode = this.node!;
        this.node = new BranchNode(v, parentNode);
        parentNode.children.add(this.node);
    }

    canUndo = $derived(!!this.node.parent);
    canRedo = $derived(!!this.node.redoTarget);

    /**
     * Branches coming off this node
     */
    children = $derived(this.node.children);

    /**
     * Undefined if root note
     */
    parent = $derived(this.node.parent);

    /**
     * Go to the selected node
     * @param node
     */
    public goto(node: BranchNode<T>) {
        this.node = node;
    }

    public undo() {
        if (!this.node.parent) return;
        const redoNode = this.node;
        this.node = this.node.parent;
        this.node.redoTarget = redoNode;
    }

    public redo() {
        if (!this.node.redoTarget) return;
        this.node = this.node.redoTarget;
    }
}

export function useBranchedStateHistory<T>(def: T) {
    return new BranchedHistory(def);
}
abdel-17 commented 5 months ago

The box abstraction is useful when you want to return boxed values directly, which allows destructuring.

const { x, y } = useMouse();

For returning values directly, I agree that $state and $derived are good enough.

TGlide commented 4 months ago

Hey @petermakeswebsites , this could really be interesting! Do you want to submit a PR?