Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add useStateWithHistory hook and use to show a block editor with undo/redo #54377

Merged
merged 7 commits into from
Sep 14, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/compose/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- `useStateWithHistory`: Add a new hook to manage state with undo/redo support.

## 6.18.0 (2023-08-31)

## 6.17.0 (2023-08-16)
Expand Down
12 changes: 12 additions & 0 deletions packages/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,18 @@ const App = () => {
};
```

### useStateWithHistory

useState with undo/redo history.

_Parameters_

- _initialValue_ `T`: Initial value.

_Returns_

- Value, setValue, hasUndo, hasRedo, undo, redo.

### useThrottle

Throttles a function similar to Lodash's `throttle`. A new throttled function will be returned and any scheduled calls cancelled if any of the arguments change, including the function to throttle, so please wrap functions created on render in components in `useCallback`.
Expand Down
1 change: 1 addition & 0 deletions packages/compose/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
"@wordpress/keycodes": "file:../keycodes",
"@wordpress/priority-queue": "file:../priority-queue",
"@wordpress/undo-manager": "file:../undo-manager",
"change-case": "^4.1.2",
"clipboard": "^2.0.8",
"mousetrap": "^1.6.5",
Expand Down
54 changes: 54 additions & 0 deletions packages/compose/src/hooks/use-state-with-history/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* WordPress dependencies
*/
import { createUndoManager } from '@wordpress/undo-manager';

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Playwright - 1

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Playwright - 4

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Puppeteer - 3

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Playwright - 3

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Puppeteer - 1

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Playwright - 2

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.1 multisite on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Puppeteer - 2

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.2 multisite on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.3 on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.1 on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.2 on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Check

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 8.1 multisite on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 8.0 multisite on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.4 on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.4 multisite on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 8.0 on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.4 (WP 6.2.2) on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.0 (WP 6.2.2) on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 8.1 on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 8.2 (WP 6.2.2) on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.3 multisite on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.0 multisite on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 7.0 on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / PHP 8.2 on ubuntu-latest

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Run performance tests

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.

Check failure on line 4 in packages/compose/src/hooks/use-state-with-history/index.ts

View workflow job for this annotation

GitHub Actions / Build Release Artifact

Cannot find module '@wordpress/undo-manager' or its corresponding type declarations.
import { useCallback, useRef, useState, useEffect } from '@wordpress/element';

/**
* useState with undo/redo history.
*
* @param initialValue Initial value.
* @return Value, setValue, hasUndo, hasRedo, undo, redo.
*/
export default function useStateWithHistory< T >( initialValue: T ) {
const manager = useRef( createUndoManager() );
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
const [ value, setValue ] = useState( initialValue );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't the internal state be equal to that of the under manager?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I thought the state was exposed, but I guess not

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why no give access to the state though, if it's a low level thing? Then you don't need an intermediate state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, it feels like an internal representation of a history. I think regardless of whether we expose it or not, we need an intermediate state, in some occasions we don't record undo/redo steps. (undoIgnore option)

const currentValue = useRef( value );
useEffect( () => {
currentValue.current = value;
}, [ value ] );

return {
value,
setValue: useCallback( ( newValue: T, isStaged: boolean ) => {
manager.current.addRecord(
[
{
id: 'object',
changes: {
prop: { from: currentValue.current, to: newValue },
},
},
],
isStaged
);
setValue( newValue );
}, [] ),
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
hasUndo: !! manager.current.getUndoRecord(),
hasRedo: !! manager.current.getRedoRecord(),
undo: useCallback( () => {
const undoRecord = manager.current.getUndoRecord();
if ( undoRecord ) {
manager.current.undo();
setValue( undoRecord[ 0 ].changes.prop.from );
}
}, [] ),
redo: useCallback( () => {
const redoRecord = manager.current.getRedoRecord();
if ( redoRecord ) {
manager.current.redo();
setValue( redoRecord[ 0 ].changes.prop.to );
}
}, [] ),
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
};
}
59 changes: 59 additions & 0 deletions packages/compose/src/hooks/use-state-with-history/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* External dependencies
*/
import { screen, render, fireEvent } from '@testing-library/react';

/**
* Internal dependencies
*/
import useStateWithHistory from '../';

const TestComponent = () => {
const { value, setValue, hasUndo, hasRedo, undo, redo } =
useStateWithHistory( 'foo' );

return (
<div>
<input
value={ value }
onChange={ ( event ) => setValue( event.target.value ) }
/>
<button className="undo" onClick={ undo } disabled={ ! hasUndo }>
Undo
</button>
<button className="redo" onClick={ redo } disabled={ ! hasRedo }>
Redo
</button>
</div>
);
};

describe( 'useStateWithHistory', () => {
it( 'should allow undo/redo', async () => {
render( <TestComponent /> );
const input = screen.getByRole( 'textbox' );
expect( input ).toHaveValue( 'foo' );
const buttonUndo = screen.getByRole( 'button', { name: 'Undo' } );
const buttonRedo = screen.getByRole( 'button', { name: 'Redo' } );
expect( buttonUndo ).toBeDisabled();
expect( buttonRedo ).toBeDisabled();

// Make a change
fireEvent.change( input, { target: { value: 'bar' } } );
expect( input ).toHaveValue( 'bar' );
expect( buttonUndo ).toBeEnabled();
expect( buttonRedo ).toBeDisabled();

// Undo the change
fireEvent.click( buttonUndo );
expect( input ).toHaveValue( 'foo' );
expect( buttonUndo ).toBeDisabled();
expect( buttonRedo ).toBeEnabled();

// Redo the change
fireEvent.click( buttonRedo );
expect( input ).toHaveValue( 'bar' );
expect( buttonUndo ).toBeEnabled();
expect( buttonRedo ).toBeDisabled();
} );
} );
1 change: 1 addition & 0 deletions packages/compose/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export { default as useKeyboardShortcut } from './hooks/use-keyboard-shortcut';
export { default as useMediaQuery } from './hooks/use-media-query';
export { default as usePrevious } from './hooks/use-previous';
export { default as useReducedMotion } from './hooks/use-reduced-motion';
export { default as useStateWithHistory } from './hooks/use-state-with-history';
export { default as useViewportMatch } from './hooks/use-viewport-match';
export { default as useResizeObserver } from './hooks/use-resize-observer';
export { default as useAsyncList } from './hooks/use-async-list';
Expand Down
4 changes: 2 additions & 2 deletions storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,15 @@ module.exports = {
...config.module,
rules: [
...config.module.rules,
{
/*{
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
// Adds a `sourceLink` parameter to the story metadata, based on the file path
test: /\/stories\/.+\.(j|t)sx?$/,
loader: path.resolve(
__dirname,
'./webpack/source-link-loader.js'
),
enforce: 'post',
},
},*/
{
test: /\.scss$/,
exclude: /\.lazy\.scss$/,
Expand Down
44 changes: 44 additions & 0 deletions storybook/stories/playground/box/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* WordPress dependencies
*/
import { useEffect, useState } from '@wordpress/element';
import { registerCoreBlocks } from '@wordpress/block-library';
import {
BlockEditorProvider,
BlockCanvas,
BlockTools,
} from '@wordpress/block-editor';

/**
* Internal dependencies
*/
import editorStyles from '../editor-styles';
import './style.css';

export default function EditorBox() {
const [ blocks, updateBlocks ] = useState( [] );

useEffect( () => {
registerCoreBlocks();
}, [] );

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="editor-box"
onKeyDown={ ( event ) => event.stopPropagation() }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we have some global keyboard shortcuts that conflict playground's shortcuts (try typing in the editor). Feels like we may want to call "preventDefault" when typing random characters in the editor but it's out of scope here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding this to the code example doesn't feel right. Should maybe move to a general playground template

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a real bug in our components, I'm not sure where though because when you type within regular inputs the events are ignored by playwright but when you type in the block editor, the events reach the playground. So I'd rather not hide it for now.

>
<BlockEditorProvider
value={ blocks }
onInput={ updateBlocks }
onChange={ updateBlocks }
settings={ {
hasFixedToolbar: true,
} }
>
<BlockTools />
<BlockCanvas height="100%" styles={ editorStyles } />
</BlockEditorProvider>
</div>
);
}
3 changes: 3 additions & 0 deletions storybook/stories/playground/box/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.editor-box {
border: 1px solid #ddd;
}
55 changes: 55 additions & 0 deletions storybook/stories/playground/fullpage/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* WordPress dependencies
*/
import { useEffect, useState } from '@wordpress/element';
import {
BlockCanvas,
BlockEditorProvider,
BlockTools,
BlockInspector,
} from '@wordpress/block-editor';
import { registerCoreBlocks } from '@wordpress/block-library';
import '@wordpress/format-library';

/**
* Internal dependencies
*/
import styles from './style.lazy.scss';
import { editorStyles } from '../editor-styles';

export default function EditorFullPage() {
const [ blocks, updateBlocks ] = useState( [] );

useEffect( () => {
registerCoreBlocks();
}, [] );

// Ensures that the CSS intended for the playground (especially the style resets)
// are only loaded for the playground and don't leak into other stories.
useEffect( () => {
styles.use();

return styles.unuse;
} );

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="playground"
onKeyDown={ ( event ) => event.stopPropagation() }
>
<BlockEditorProvider
value={ blocks }
onInput={ updateBlocks }
onChange={ updateBlocks }
>
<div className="playground__sidebar">
<BlockInspector />
</div>
<BlockTools className="playground__content">
<BlockCanvas height="100%" styles={ editorStyles } />
</BlockTools>
</BlockEditorProvider>
</div>
);
}
90 changes: 8 additions & 82 deletions storybook/stories/playground/index.story.js
Original file line number Diff line number Diff line change
@@ -1,58 +1,9 @@
/**
* WordPress dependencies
*/
import { useEffect, useState } from '@wordpress/element';
import {
BlockCanvas,
BlockEditorProvider,
BlockTools,
BlockInspector,
} from '@wordpress/block-editor';
import { registerCoreBlocks } from '@wordpress/block-library';
import '@wordpress/format-library';

/**
* Internal dependencies
*/
import styles from './style.lazy.scss';
import { editorStyles } from './editor-styles';

function App() {
const [ blocks, updateBlocks ] = useState( [] );

useEffect( () => {
registerCoreBlocks();
}, [] );

// Ensures that the CSS intended for the playground (especially the style resets)
// are only loaded for the playground and don't leak into other stories.
useEffect( () => {
styles.use();

return styles.unuse;
} );

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="playground"
onKeyDown={ ( event ) => event.stopPropagation() }
>
<BlockEditorProvider
value={ blocks }
onInput={ updateBlocks }
onChange={ updateBlocks }
>
<div className="playground__sidebar">
<BlockInspector />
</div>
<BlockTools className="playground__content">
<BlockCanvas height="100%" styles={ editorStyles } />
</BlockTools>
</BlockEditorProvider>
</div>
);
}
import EditorFullPage from './fullpage';
import EditorBox from './box';
import EditorWithUndoRedo from './with-undo-redo';

export default {
title: 'Playground/Block Editor',
Expand All @@ -62,38 +13,13 @@ export default {
};

export const _default = () => {
return <App />;
return <EditorFullPage />;
};

function EditorBox() {
const [ blocks, updateBlocks ] = useState( [] );

useEffect( () => {
registerCoreBlocks();
}, [] );

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="editor-box"
style={ { border: '1px solid #eee' } }
onKeyDown={ ( event ) => event.stopPropagation() }
>
<BlockEditorProvider
value={ blocks }
onInput={ updateBlocks }
onChange={ updateBlocks }
settings={ {
hasFixedToolbar: true,
} }
>
<BlockTools />
<BlockCanvas height="100%" styles={ editorStyles } />
</BlockEditorProvider>
</div>
);
}

export const Box = () => {
return <EditorBox />;
};

export const UndoRedo = () => {
return <EditorWithUndoRedo />;
};
Loading
Loading