8000 Preserve Undo History in Markdown Preview Mode by l28j · Pull Request #22026 · forem/forem · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Preserve Undo History in Markdown Preview Mode #22026

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
62 changes: 42 additions & 20 deletions app/javascript/article-form/articleForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,20 @@ export class ArticleForm extends Component {

componentDidMount() {
window.addEventListener('beforeunload', this.localStoreContent);
window.addEventListener('keydown', this.handleBlockUndoRedo);
}

componentWillUnmount() {
window.removeEventListener('beforeunload', this.localStoreContent);
window.removeEventListener('keydown', this.handleBlockUndoRedo);
}

componentDidUpdate() {
componentDidUpdate(prevProps, prevState) {
if (this.state.previewShowing && !prevState.previewShowing) {
window.addEventListener('keydown', this.handleBlockUndoRedo);
} else if (!this.state.previewShowing && prevState.previewShowing) {
window.removeEventListener('keydown', this.handleBlockUndoRedo);
}
const { previewResponse } = this.state;

if (previewResponse?.processed_html) {
Expand All @@ -172,6 +179,16 @@ export class ArticleForm extends Component {
this.constructor.handleAsciinemaPreview();
}
}

handleBlockUndoRedo = (e) => {
if (
this.state.previewShowing &&
((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'y'))
) {
e.preventDefault();
e.stopPropagation();
}
};

localStoreContent = () => {
if (sessionStorage.getItem('isSigningOut') === 'true') {
Expand Down Expand Up @@ -483,6 +500,27 @@ export class ArticleForm extends Component {
{previewShowing && !previewLoading ? 'Preview loaded' : null}
</span>


<Form
key={formKey}
titleDefaultValue={title}
titleOnChange={linkState(this, 'title')}
tagsDefaultValue={tagList}
tagsOnInput={linkState(this, 'tagList')}
bodyDefaultValue={bodyMarkdown}
bodyOnChange={linkState(this, 'bodyMarkdown')}
bodyHasFocus={false}
version={version}
mainImage={mainImage}
>
errors={errors}
switchHelpContext={this.switchHelpContext}
coverImageHeight={coverImageHeight}
coverImageCrop={coverImageCrop}
previewMode={previewShowing || previewLoading}
/>


{previewShowing || previewLoading ? (
<Preview
previewLoading={previewLoading}
Expand All @@ -491,25 +529,9 @@ export class ArticleForm extends Component {
errors={errors}
markdownLintErrors={markdownLintErrors}
/>
) : (
<Form
key={formKey}
titleDefaultValue={title}
titleOnChange={linkState(this, 'title')}
tagsDefaultValue={tagList}
tagsOnInput={linkState(this, 'tagList')}
bodyDefaultValue={bodyMarkdown}
bodyOnChange={linkState(this, 'bodyMarkdown')}
bodyHasFocus={false}
version={version}
mainImage={mainImage}
>
errors={errors}
switchHelpContext={this.switchHelpContext}
coverImageHeight={coverImageHeight}
coverImageCrop={coverImageCrop}
/>
)}
) : null}



<Help
previewShowing={previewShowing}
Expand Down
7 changes: 6 additions & 1 deletion app/javascript/article-form/components/Form.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ export const Form = ({
errors,
coverImageCrop,
coverImageHeight,
previewMode,
}) => {
return (
<div className="crayons-article-form__content crayons-card">
<div className="crayons-article-form__content crayons-card"
style={{ display: previewMode ? 'none' : 'block' }}
>

{errors && <ErrorList errors={errors} />}

{version === 'v2' && (
Expand Down Expand Up @@ -64,6 +68,7 @@ Form.propTypes = {
errors: PropTypes.object,
coverImageHeight: PropTypes.string.isRequired,
coverImageCrop: PropTypes.string.isRequired,
previewMode: PropTypes.bool,
};

Form.displayName = 'Form';
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { JSDOM } from 'jsdom';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import { Preview } from '../Preview';
import { useState } from 'preact/hooks';
import { Form } from '../Form';
import '@testing-library/jest-dom';

const doc = new JSDOM('<!doctype html><html><body></body></html>');
Expand Down Expand Up @@ -211,3 +213,64 @@ describe('<Preview />', () => {

// TODO: need to write a test for the cover image for v1
});

// Integration test: Form stays mounted (invisible) when switching to Preview
describe('Preview + Form integration', () => {
/**
* This test simulates the real scenario where the Form is always rendered,
* but hidden (display: none) when in Preview mode.
* This ensures the browser keeps the undo/redo history alive.
*/
function TestWrapper() {
const [preview, setPreview] = useState(false);

return (
<div>
<button => setPreview((p) => !p)}>Toggle Preview</button>
<div
data-testid="form-container"
style={{ display: preview ? 'none' : 'block' }}
>
<Form
titleDefaultValue="Test"
titleOnChange={() => {}}
tagsDefaultValue="tag"
tagsOnInput={() => {}}
bodyDefaultValue="markdown"
bodyOnChange={() => {}}
bodyHasFocus={false}
version="v2"
mainImage={null}
=> {}}
errors={null}
switchHelpContext={() => {}}
coverImageHeight="400"
coverImageCrop="center"
previewMode={preview}
/>
</div>
{preview && (
<Preview
previewLoading={false}
previewResponse={{ processed_html: '<p>preview</p>' }}
articleState={{}}
errors={null}
/>
)}
</div>
);
}

it('keeps the Form mounted in the DOM (but hidden) when switching to Preview mode', () => {
const { getByText, getByTestId } = render(<TestWrapper />);
// Form is visible initially
expect(getByTestId('form-container')).toBeVisible();

// Switch to preview mode
getByText('Toggle Preview').click();

// Form is still in the DOM, but not visible
expect(getByTestId('form-container')).toBeInTheDocument();
expect(getByTestId('form-container')).not.toBeVisible();
});
});
107 changes: 107 additions & 0 deletions cypress/e2e/seededFlows/publishingFlows/previewPost.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,113 @@ describe('Post Editor', () => {
);
});
});

/*
We use realType and realPress to simulate real keyboard input events.
This ensures native undo/redo (e.g., Ctrl+Z) works as expected during the test.
Info on cypress-real-events (https://github.com/dmtrKovalenko/cypress-real-events?tab=readme-ov-file)
*/
it('should preserve undo history after switching to preview and back', () => {
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');

// Clear and select title
cy.get('@articleForm')
.findByLabelText('Post Title')
.click();

// Type "Title" using realType
cy.realType('Title');

// Clear and select publication body
cy.get('@articleForm')
.findByLabelText('Post Content')
.as('editorField')
.clear()
.click();

cy.realType("Body");

// Switch to Preview Mode
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.should('exist')
.click();

cy.contains('Loading preview').should('exist');
cy.contains('Loading preview').should('not.exist');
cy.contains('Create Post').should('exist');
cy.contains('Title').should('exist');
cy.contains('Body').should('exist');

// Switch to Edit Mode
cy.get('@articleForm')
.findByRole('button', { name: /^Edit$/i })
.should('exist')
.click();

cy.get('@articleForm')
.findByLabelText('Post Content')
.should('have.value', 'Body')
.as('editorField')
.click();

// Press Ctrl+Z 6 times using realPress to undo word "Body" and sufix "-le" from Title
Cypress._.times(6, () => {
cy.realPress(['Control', 'z']);
});

cy.get('@articleForm')
.findByLabelText('Post Content')
.should('have.value', '');

//Prefix "Tit" from word "Title should remain in markdown"
cy.get('@articleForm')
.findByLabelText('Post Title')
.should('have.value', 'Tit');
});

it('should not be able to use undo while in preview', () => {
cy.findByRole('form', { name: /^Edit post$/i }).as('articleForm');

cy.get('@articleForm')
.findByLabelText('Post Content')
.as('editorField')
.clear()
.click();

// Type using realType
cy.realType("Can only Undo on Edit Mode");

// Switch to Preview Mode
cy.get('@articleForm')
.findByRole('button', { name: /^Preview$/i })
.should('exist')
.click();

cy.contains('Loading preview').should('exist');
cy.contains('Loading preview').should('not.exist');
cy.contains('Can only Undo on Edit Mode').should('exist');

// Press ctrl+z any number of times, there should be no changes
Cypress._.times(7, () => {
cy.realPress(['Control', 'z']);
});

cy.contains('Can only Undo on Edit Mode').should('exist');

// Switch to Edit Mode
cy.get('@articleForm')
.findByRole('button', { name: /^Edit$/i })
.should('exist')
.click();

cy.realPress(['Control', 'z']);

// Last written caracter should be removed
cy.get('@articleForm')
.findByLabelText('Post Content')
.should('have.value', 'Can only Undo on Edit Mod');
});
});

describe('Accessibility suggestions', () => {
Expand Down
1 change: 1 addition & 0 deletions cypress/support/e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import '@cypress/code-coverage/support';
import '@testing-library/cypress/add-commands';
import 'cypress-file-upload';
import 'cypress-failed-log';
import 'cypress-real-events/support';

// Custom assertions
import './assertions';
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"cypress-file-upload": "^5.0.8",
"cypress-multi-reporters": "^1.6.4",
"cypress-pipe": "^2.0.0",
"cypress-real-events": "^1.14.0",
"eslint": "^8.57.0",
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^8.10.0",
Expand Down
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8876,6 +8876,15 @@ __metadata:
languageName: node
linkType: hard

"cypress-real-events@npm:^1.14.0":
version: 1.14.0
resolution: "cypress-real-events@npm:1.14.0"
peerDependencies:
cypress: ^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x || ^13.x || ^14.x
checksum: 10c0/da81f8f5bf829f3bcd2c26a532656d7c588807df7198b2eb72b882895ca0265c8bab49eaea5c78374fd52c196ec3f3f0937245af421ee13f8ce3bf06631b43af
languageName: node
linkType: hard

"cypress@npm:^13.7.2":
version: 13.7.2
resolution: "cypress@npm:13.7.2"
Expand Down Expand Up @@ -9365,6 +9374,7 @@ __metadata:
cypress-file-upload: "npm:^5.0.8"
cypress-multi-reporters: "npm:^1.6.4"
cypress-pipe: "npm:^2.0.0"
cypress-real-events: "npm:^1.14.0"
esbuild: "npm:^0.19.12"
esbuild-plugin-stimulus: "npm:^0.1.5"
esbuild-plugin-svgr: "npm:^2.1.0"
Expand Down
Loading
0