diff --git a/docs/.vuepress/components/UndoExample.vue b/docs/.vuepress/components/UndoExample.vue new file mode 100644 index 000000000..381985518 --- /dev/null +++ b/docs/.vuepress/components/UndoExample.vue @@ -0,0 +1,30 @@ + + + Type a text to enable undo and redo + + + + Undo + Redo + + + + Prev + {{ prev }} + + + + Next + {{ next }} + + + + + diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index d7b80dc22..3dec5d0cf 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -213,6 +213,12 @@ module.exports = { collapsable: false, children: [["composable/meta/title", "Title"]] }, + { + title: "state", + sidebarDepth: 1, + collapsable: false, + children: [["composable/state/undo", "Undo"]] + }, { title: "External", sidebarDepth: 1, diff --git a/docs/README.md b/docs/README.md index 3ffcc5a61..287e57357 100644 --- a/docs/README.md +++ b/docs/README.md @@ -112,6 +112,10 @@ Check out the [examples folder](examples) or start hacking on [codesandbox](http - [Title](https://pikax.me/vue-composable/composable/meta/title) - reactive `document.title` +### State + +- [Undo](https://pikax.me/vue-composable/composable/state/undo) - Tracks variable history, to allow `undo` and `redo` + ### Web - [Fetch](https://pikax.me/vue-composable/composable/web/fetch) - reactive `fetch` wrapper diff --git a/docs/api/vue-composable.api.md b/docs/api/vue-composable.api.md index eeb1c0776..f946761dc 100644 --- a/docs/api/vue-composable.api.md +++ b/docs/api/vue-composable.api.md @@ -534,6 +534,9 @@ export interface LocalStorageReturn { // @public (undocumented) export type LocalStorageTyped = string; +// @public (undocumented) +export const MAX_ARRAY_SIZE: number; + // @public (undocumented) export function minMax(val: number, min: number, max: number): number; @@ -896,6 +899,31 @@ export interface StorageSerializer { stringify(item: T): string; } +// @public (undocumented) +export interface UndoOperation { + (step: number): void; + (): void; +} + +// @public (undocumented) +export interface UndoOptions { + clone: (entry: T) => T; + deep: boolean; + maxLength: number; +} + +// @public (undocumented) +export interface UndoReturn { + jump(delta: number): void; + next: ComputedRef; + prev: ComputedRef; + redo(): void; + redo(step: number): void; + undo(): void; + undo(step: number): void; + value: Ref; +} + // @public (undocumented) export function unwrap(o: RefTyped): T; @@ -1617,6 +1645,15 @@ export function useStorage( // @public (undocumented) export function useTitle(overrideTitle?: string | null): Ref; +// @public (undocumented) +export function useUndo(): UndoReturn; + +// @public (undocumented) +export function useUndo( + defaultValue: RefTyped, + options?: Partial> +): UndoReturn; + // Warning: (ae-forgotten-export) The symbol "UseValidation" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ValidationOutput" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ValidationGroupResult" needs to be exported by the entry point index.d.ts diff --git a/docs/composable/state/undo.md b/docs/composable/state/undo.md new file mode 100644 index 000000000..6ae1f8072 --- /dev/null +++ b/docs/composable/state/undo.md @@ -0,0 +1,118 @@ +# Undo + +> Tracks variable history, to allow `undo` and `redo` + +## Parameters + +```js +import { useUndo } from "vue-composable"; + +export interface UndoOptions { + /** + * Watch `deep` option for changes + */ + deep: boolean; + + /** + * Max history change + * @default MAX_ARRAY_SIZE + */ + maxLength: number; + + /** + * Clone strategy + * @default (x)=>x + */ + clone: (entry: T) => T; +} + +const defaultOptions = { + deep: undefined, + + + maxLength: MAX_ARRAY_SIZE, + + clone(x) { + return x; + } +} + + +useUndo(defaultValue?, options?); +``` + +| Parameters | Type | Required | Default | Description | +| ------------ | ----------- | -------- | ---------------- | --------------------- | +| defaultValue | `Ref|T` | `false` | `undefined` | Default value | +| options | `(x: T)=>T` | `false` | `defaultOptions` | Configuration options | + +## State + +The `useUndo` function exposes the following reactive state: + +```js +import { useUndo } from "vue-composable"; + +const { value, prev, next } = useUndo(); +``` + +| State | Type | Description | +| ----- | ---------- | ------------------------------------------ | +| value | `Ref` | State value | +| prev | `Ref` | Array of prev states | +| next | `Ref` | Array of next states, only if you `undo()` | + +## Methods + +The `useUndo` function exposes the following methods: + +```js +import { useUndo } from "vue-composable"; + +const { jump, undo, redo } = useUndo(); +``` + +| Signature | Description | +| ------------- | -------------------------------------------------------------------------------------------- | +| `jump(delta)` | moves the cursor to `delta`, if delta is positive it will `undo`, if negative it will `redo` | +| `undo(n?)` | Undo the state to `n` default to 1 | +| `redo(n?)` | Redo the state to `n` default to 1 | + +## Example + + + +### Code + +```vue + + + Type a text to enable undo and redo + + + + Undo + Redo + + + + Prev + {{ prev }} + + + + Next + {{ next }} + + + + + +``` diff --git a/packages/vue-composable/README.md b/packages/vue-composable/README.md index 5cfc0612f..281ed62d8 100644 --- a/packages/vue-composable/README.md +++ b/packages/vue-composable/README.md @@ -115,6 +115,10 @@ Check our [documentation](https://pikax.me/vue-composable/) - [Title](https://pikax.me/vue-composable/composable/meta/title) - reactive `document.title` +### State + +- [Undo](https://pikax.me/vue-composable/composable/state/undo) - Tracks variable history, to allow `undo` and `redo` + ### Web - [Fetch](https://pikax.me/vue-composable/composable/web/fetch) - reactive `fetch` wrapper diff --git a/packages/vue-composable/__tests__/state/undo.spec.ts b/packages/vue-composable/__tests__/state/undo.spec.ts new file mode 100644 index 000000000..480af8963 --- /dev/null +++ b/packages/vue-composable/__tests__/state/undo.spec.ts @@ -0,0 +1,68 @@ +import { useUndo } from "../../src"; +import { ref } from "../../src/api"; +describe("undo", () => { + it("should work", () => { + const v = ref(0); + + const undo = useUndo(v); + + expect(undo.value).toBe(v); + + v.value = 1; + expect(undo.prev.value).toHaveLength(1); + + v.value++; + expect(undo.prev.value).toHaveLength(2); + + undo.undo(); + expect(v.value).toBe(1); + expect(undo.prev.value).toHaveLength(2); + expect(undo.next.value).toHaveLength(1); + + undo.redo(); + expect(undo.prev.value).toHaveLength(2); + expect(undo.next.value).toHaveLength(0); + + const x = 10; + for (let i = 0; i < x; ++i) { + v.value = i; + } + expect(undo.prev.value).toHaveLength(2 + x); + expect(undo.next.value).toHaveLength(0); + + undo.jump(x); + + expect(undo.prev.value).toHaveLength(3); + expect(undo.next.value).toHaveLength(x); + + v.value = 42; + expect(undo.prev.value).toHaveLength(3); + expect(undo.next.value).toHaveLength(0); + }); + + it("should only store maxItems", () => { + const undo = useUndo(1, { maxLength: 2 }); + + undo.value.value++; + expect(undo.prev.value).toStrictEqual([1]); + + undo.value.value++; + expect(undo.prev.value).toStrictEqual([2, 1]); + + undo.value.value++; + expect(undo.prev.value).toStrictEqual([3, 2]); + + undo.value.value++; + expect(undo.prev.value).toStrictEqual([4, 3]); + }); + + it("should use clone function", () => { + const clone = jest.fn() as any; + const undo = useUndo({ a: 1 }, { clone }); + + expect(clone).toHaveBeenCalled(); + + undo.value.value = { a: 2 }; + expect(clone).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/vue-composable/src/index.ts b/packages/vue-composable/src/index.ts index 4ee094af8..125e2d41e 100644 --- a/packages/vue-composable/src/index.ts +++ b/packages/vue-composable/src/index.ts @@ -14,7 +14,9 @@ export * from "./validation"; export * from "./i18n"; export * from "./meta"; export * from "./ssr"; +export * from "./state"; export const VERSION = __VERSION__; +// istanbul ignore next export const VUE_VERSION: "2" | "3" = __VUE_2__ ? "2" : "3"; export const COMMIT = __COMMIT__; diff --git a/packages/vue-composable/src/state/index.ts b/packages/vue-composable/src/state/index.ts new file mode 100644 index 000000000..dad0cc440 --- /dev/null +++ b/packages/vue-composable/src/state/index.ts @@ -0,0 +1 @@ +export * from "./undo"; diff --git a/packages/vue-composable/src/state/undo.ts b/packages/vue-composable/src/state/undo.ts new file mode 100644 index 000000000..80661fac7 --- /dev/null +++ b/packages/vue-composable/src/state/undo.ts @@ -0,0 +1,162 @@ +import { ref, computed, watch, Ref, ComputedRef } from "../api"; +import { RefTyped, MAX_ARRAY_SIZE, wrap } from "../utils"; + +export interface UndoOptions { + /** + * Watch `deep` option for changes + */ + deep: boolean; + + /** + * Max history change + * @default MAX_ARRAY_SIZE + * + */ + maxLength: number; + + /** + * Clone strategy + * @default (x)=>x + */ + clone: (entry: T) => T; +} + +export interface UndoOperation { + /** + * Move state + * @param step - Positive position + */ + (step: number): void; + /** + * Move 1 step in history + */ + (): void; +} + +export interface UndoReturn { + /** + * Current value + */ + value: Ref; + + /** + * Undo state to the previous + */ + undo(): void; + + /** + * Undo state + * @param step - Positive position + */ + undo(step: number): void; + + /** + * Redo state to the previous + */ + redo(): void; + /** + * Redo state + * @param step - Positive position + */ + redo(step: number): void; + + /** + * Moves the cursor to delta + * @param delta - If positive it will `undo` the state, if negative it will `redo` + */ + jump(delta: number): void; + + /** + * List of previous states + */ + prev: ComputedRef; + /** + * List of next states + * This is only populated if you `undo` or `jump` + */ + next: ComputedRef; +} + +export function useUndo(): UndoReturn; + +export function useUndo( + defaultValue: RefTyped, + options?: Partial> +): UndoReturn; + +export function useUndo( + defaultValue?: RefTyped, + options?: Partial> +): UndoReturn { + const current = wrap(defaultValue!); + + const timeline = ref([]); + const position = ref(0); + + const maxLen = (options && options.maxLength) || MAX_ARRAY_SIZE; + const clone = (options && options.clone) || ((t: any) => t); + + watch( + current, + c => { + if (timeline.value[position.value] === c) { + //ignore because is the same value + return; + } + + // new value added + if (position.value > 0) { + const pos = position.value; + timeline.value.splice(0, pos); + // reset position + position.value = 0; + } + + if (timeline.value.length > maxLen) { + timeline.value.pop(); + } + + timeline.value.unshift(clone(c)); + }, + { + ...options, + immediate: true, + flush: "sync" + } + ); + + const undo = (step = 1) => jump(step); + const redo = (step = 1) => jump(-step); + + const jump = (delta: number) => { + const s = + Math.sign(delta) <= 0 + ? Math.max(delta, -next.value.length) + : Math.min(delta, prev.value.length); + + position.value += s; + current.value = timeline.value[position.value]; + }; + + const prev = computed(() => { + // hide current + const p = position.value === 0 ? 1 : position.value; + return timeline.value.slice(p); + }); + const next = computed(() => { + // hide current + const p = position.value === 0 ? 1 : 0; + return timeline.value.slice(p, position.value); + }); + + return { + value: current, + + undo, + redo, + jump, + + prev, + next + }; +} diff --git a/packages/vue-composable/src/utils.ts b/packages/vue-composable/src/utils.ts index cd96f4d9c..39278592b 100644 --- a/packages/vue-composable/src/utils.ts +++ b/packages/vue-composable/src/utils.ts @@ -55,6 +55,9 @@ export function promisedTimeout(timeout: number): Promise { }); } +// https://v8.dev/blog/react-cliff +export const MAX_ARRAY_SIZE = 2 ** 32 - 2; + export function minMax(val: number, min: number, max: number) { if (val < min) return min; if (val > max) return max; diff --git a/readme.md b/readme.md index 5cfc0612f..281ed62d8 100644 --- a/readme.md +++ b/readme.md @@ -115,6 +115,10 @@ Check our [documentation](https://pikax.me/vue-composable/) - [Title](https://pikax.me/vue-composable/composable/meta/title) - reactive `document.title` +### State + +- [Undo](https://pikax.me/vue-composable/composable/state/undo) - Tracks variable history, to allow `undo` and `redo` + ### Web - [Fetch](https://pikax.me/vue-composable/composable/web/fetch) - reactive `fetch` wrapper
Type a text to enable undo and redo
+ Prev + {{ prev }} +
+ Next + {{ next }} +