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

feat: visual editing (click-to-edit) #7374

Open
wants to merge 3 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
6 changes: 5 additions & 1 deletion dev-test/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ collections: # A list of collections the CMS should be able to edit
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
create: true # Allow users to create new documents in this collection
editor:
visualEditing: true
view_filters:
- label: Posts With Index
field: title
Expand Down Expand Up @@ -60,7 +62,9 @@ collections: # A list of collections the CMS should be able to edit
folder: '_restaurants'
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
create: true # Allow users to create new documents in this collection
create: true # Allow users to create new documents in this collection
editor:
visualEditing: true
fields: # The fields each document in this collection have
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
- { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' }
Expand Down
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/decap-cms-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"dependencies": {
"@iarna/toml": "2.2.5",
"@reduxjs/toolkit": "^1.9.1",
"@vercel/stega": "^0.1.2",
"ajv": "8.12.0",
"ajv-errors": "^3.0.0",
"ajv-keywords": "^5.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@ class EditorControl extends React.Component {
removeInsertedMedia: PropTypes.func.isRequired,
persistMedia: PropTypes.func.isRequired,
onValidate: PropTypes.func,
processControlRef: PropTypes.func,
controlRef: PropTypes.func,
query: PropTypes.func.isRequired,
queryHits: PropTypes.object,
Expand Down Expand Up @@ -201,7 +200,6 @@ class EditorControl extends React.Component {
removeInsertedMedia,
persistMedia,
onValidate,
processControlRef,
controlRef,
query,
queryHits,
Expand Down Expand Up @@ -329,7 +327,6 @@ class EditorControl extends React.Component {
resolveWidget={resolveWidget}
widget={widget}
getEditorComponents={getEditorComponents}
ref={processControlRef && partial(processControlRef, field)}
controlRef={controlRef}
editorControl={ConnectedEditorControl}
query={query}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,17 @@ export default class ControlPane extends React.Component {
selectedLocale: this.props.locale,
};

componentValidate = {};
childRefs = {};

controlRef(field, wrappedControl) {
controlRef = (field, wrappedControl) => {
if (!wrappedControl) return;
const name = field.get('name');
this.childRefs[name] = wrappedControl;
};

this.componentValidate[name] =
wrappedControl.innerWrappedControl?.validate || wrappedControl.validate;
}
getControlRef = field => wrappedControl => {
this.controlRef(field, wrappedControl);
};

handleLocaleChange = val => {
this.setState({ selectedLocale: val });
Expand Down Expand Up @@ -152,7 +154,11 @@ export default class ControlPane extends React.Component {
validate = async () => {
this.props.fields.forEach(field => {
if (field.get('widget') === 'hidden') return;
this.componentValidate[field.get('name')]();
const control = this.childRefs[field.get('name')];
const validateFn = control?.innerWrappedControl?.validate ?? control?.validate;
if (validateFn) {
validateFn();
}
});
};

Expand All @@ -165,6 +171,14 @@ export default class ControlPane extends React.Component {
}
};

focus(path) {
const [fieldName, ...remainingPath] = path.split('.');
const control = this.childRefs[fieldName];
if (control?.focus) {
control.focus(remainingPath.join('.'));
}
}

render() {
const { collection, entry, fields, fieldsMetaData, fieldsErrors, onChange, onValidate, t } =
this.props;
Expand Down Expand Up @@ -227,8 +241,7 @@ export default class ControlPane extends React.Component {
onChange(field, newValue, newMetadata, i18n);
}}
onValidate={onValidate}
processControlRef={this.controlRef.bind(this)}
controlRef={this.controlRef}
controlRef={this.getControlRef(field)}
entry={entry}
collection={collection}
isDisabled={isDuplicate}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default class Widget extends Component {
fieldsErrors: ImmutablePropTypes.map,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func,
controlRef: PropTypes.func,
onOpenMediaLibrary: PropTypes.func.isRequired,
onClearMediaControl: PropTypes.func.isRequired,
onRemoveMediaControl: PropTypes.func.isRequired,
Expand All @@ -55,7 +56,6 @@ export default class Widget extends Component {
widget: PropTypes.object.isRequired,
getEditorComponents: PropTypes.func.isRequired,
isFetching: PropTypes.bool,
controlRef: PropTypes.func,
query: PropTypes.func.isRequired,
clearSearch: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired,
Expand Down Expand Up @@ -112,8 +112,29 @@ export default class Widget extends Component {
*/
const { shouldComponentUpdate: scu } = this.innerWrappedControl;
this.wrappedControlShouldComponentUpdate = scu && scu.bind(this.innerWrappedControl);

// Call the control ref if provided, passing this Widget instance
if (this.props.controlRef) {
this.props.controlRef(this);
}
};

focus(path) {
// Try widget's custom focus method first
if (this.innerWrappedControl?.focus) {
this.innerWrappedControl.focus(path);
} else {
// Fall back to focusing by ID for simple widgets
const element = document.getElementById(this.props.uniqueFieldId);
element?.focus();
}
// After focusing, ensure the element is visible
const label = document.querySelector(`label[for="${this.props.uniqueFieldId}"]`);
if (label) {
label.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}

getValidateValue = () => {
let value = this.innerWrappedControl?.getValidateValue?.() || this.props.value;
// Convert list input widget value to string for validation test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ class EditorInterface extends Component {
i18nVisible: localStorage.getItem(I18N_VISIBLE) !== 'false',
};

handleFieldClick = path => {
this.controlPaneRef?.focus(path);
};

handleSplitPaneDragStart = () => {
this.setState({ showEventBlocker: true });
};
Expand Down Expand Up @@ -298,6 +302,7 @@ class EditorInterface extends Component {
fields={fields}
fieldsMetaData={fieldsMetaData}
locale={leftPanelLocale}
onFieldClick={this.handleFieldClick}
/>
</PreviewPaneContainer>
</StyledSplitPane>
Expand Down Expand Up @@ -381,7 +386,7 @@ class EditorInterface extends Component {
title={t('editor.editorInterface.togglePreview')}
/>
)}
{scrollSyncVisible && (
{scrollSyncVisible && !collection.getIn(['editor', 'visualEditing']) && (
<EditorToggle
isActive={scrollSyncEnabled}
onClick={this.handleToggleScrollSync}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,63 @@ import React from 'react';
import { isElement } from 'react-is';
import { ScrollSyncPane } from 'react-scroll-sync';
import { FrameContextConsumer } from 'react-frame-component';
import { vercelStegaDecode } from '@vercel/stega';

/**
* We need to create a lightweight component here so that we can access the
* context within the Frame. This allows us to attach the ScrollSyncPane to the
* body.
* PreviewContent renders the preview component and optionally handles visual editing interactions.
* By default it uses scroll sync, but can be configured to use visual editing instead.
*/
class PreviewContent extends React.Component {
render() {
handleClick = e => {
const { previewProps, onFieldClick } = this.props;
const visualEditing = previewProps?.collection?.getIn(['editor', 'visualEditing'], false);

if (!visualEditing) {
return;
}

try {
const text = e.target.textContent;
const decoded = vercelStegaDecode(text);
if (decoded?.decap) {
if (onFieldClick) {
onFieldClick(decoded.decap);
}
}
} catch (err) {
console.log('Visual editing error:', err);
}
};

renderPreview() {
const { previewComponent, previewProps } = this.props;
return (
<div onClick={this.handleClick}>
{isElement(previewComponent)
? React.cloneElement(previewComponent, previewProps)
: React.createElement(previewComponent, previewProps)}
</div>
);
}

render() {
const { previewProps } = this.props;
const visualEditing = previewProps?.collection?.getIn(['editor', 'visualEditing'], false);
const showScrollSync = !visualEditing;

return (
<FrameContextConsumer>
{context => (
<ScrollSyncPane attachTo={context.document.scrollingElement}>
{isElement(previewComponent)
? React.cloneElement(previewComponent, previewProps)
: React.createElement(previewComponent, previewProps)}
</ScrollSyncPane>
)}
{context => {
const preview = this.renderPreview();
if (showScrollSync) {
return (
<ScrollSyncPane attachTo={context.document.scrollingElement}>
{preview}
</ScrollSyncPane>
);
}
return preview;
}}
</FrameContextConsumer>
);
}
Expand All @@ -29,6 +68,7 @@ class PreviewContent extends React.Component {
PreviewContent.propTypes = {
previewComponent: PropTypes.func.isRequired,
previewProps: PropTypes.object,
onFieldClick: PropTypes.func,
};

export default PreviewContent;
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import Frame, { FrameContextConsumer } from 'react-frame-component';
import { lengths } from 'decap-cms-ui-default';
import { connect } from 'react-redux';
import { encodeEntry } from 'decap-cms-lib-util/src/stega';

import {
resolveWidget,
Expand Down Expand Up @@ -92,6 +93,7 @@ export class PreviewPane extends React.Component {
if (field.get('meta')) {
value = this.props.entry.getIn(['meta', field.get('name')]);
}

const nestedFields = field.get('fields');
const singleField = field.get('field');
const metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map());
Expand Down Expand Up @@ -226,9 +228,18 @@ export class PreviewPane extends React.Component {

this.inferFields();

const visualEditing = collection.getIn(['editor', 'visualEditing'], false);

// Only encode entry data if visual editing is enabled
const previewEntry = visualEditing
? entry.set('data', encodeEntry(entry.get('data'), this.props.fields))
: entry;

const previewProps = {
...this.props,
widgetFor: this.widgetFor,
entry: previewEntry,
widgetFor: (name, fields, values = previewEntry.get('data'), fieldsMetaData) =>
this.widgetFor(name, fields, values, fieldsMetaData),
widgetsFor: this.widgetsFor,
getCollection: this.getCollection,
};
Expand Down Expand Up @@ -260,6 +271,7 @@ export class PreviewPane extends React.Component {
return (
<EditorPreviewContent
{...{ previewComponent, previewProps: { ...previewProps, document, window } }}
onFieldClick={this.props.onFieldClick}
/>
);
}}
Expand All @@ -276,6 +288,7 @@ PreviewPane.propTypes = {
entry: ImmutablePropTypes.map.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onFieldClick: PropTypes.func,
};

function mapStateToProps(state) {
Expand Down
Loading
Loading