Skip to content

Commit 989c2dd

Browse files
fgnassdemshy
andauthored
feat: visual editing (click-to-edit) (#7374)
* refactor: clean up controlRef handling * feat: add click-to-edit * test: update snapshots --------- Co-authored-by: Anze Demsar <[email protected]>
1 parent 8b8e873 commit 989c2dd

File tree

17 files changed

+397
-63
lines changed

17 files changed

+397
-63
lines changed

dev-test/config.yml

+5-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ collections: # A list of collections the CMS should be able to edit
1717
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
1818
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
1919
create: true # Allow users to create new documents in this collection
20+
editor:
21+
visualEditing: true
2022
view_filters:
2123
- label: Posts With Index
2224
field: title
@@ -60,7 +62,9 @@ collections: # A list of collections the CMS should be able to edit
6062
folder: '_restaurants'
6163
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
6264
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
63-
create: true # Allow users to create new documents in this collection
65+
create: true # Allow users to create new documents in this collection
66+
editor:
67+
visualEditing: true
6468
fields: # The fields each document in this collection have
6569
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
6670
- { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' }

package-lock.json

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/decap-cms-core/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"dependencies": {
2727
"@iarna/toml": "2.2.5",
2828
"@reduxjs/toolkit": "^1.9.1",
29+
"@vercel/stega": "^0.1.2",
2930
"ajv": "8.12.0",
3031
"ajv-errors": "^3.0.0",
3132
"ajv-keywords": "^5.0.0",

packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js

-3
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ class EditorControl extends React.Component {
139139
removeInsertedMedia: PropTypes.func.isRequired,
140140
persistMedia: PropTypes.func.isRequired,
141141
onValidate: PropTypes.func,
142-
processControlRef: PropTypes.func,
143142
controlRef: PropTypes.func,
144143
query: PropTypes.func.isRequired,
145144
queryHits: PropTypes.object,
@@ -201,7 +200,6 @@ class EditorControl extends React.Component {
201200
removeInsertedMedia,
202201
persistMedia,
203202
onValidate,
204-
processControlRef,
205203
controlRef,
206204
query,
207205
queryHits,
@@ -329,7 +327,6 @@ class EditorControl extends React.Component {
329327
resolveWidget={resolveWidget}
330328
widget={widget}
331329
getEditorComponents={getEditorComponents}
332-
ref={processControlRef && partial(processControlRef, field)}
333330
controlRef={controlRef}
334331
editorControl={ConnectedEditorControl}
335332
query={query}

packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js

+21-8
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,17 @@ export default class ControlPane extends React.Component {
9999
selectedLocale: this.props.locale,
100100
};
101101

102-
componentValidate = {};
102+
childRefs = {};
103103

104-
controlRef(field, wrappedControl) {
104+
controlRef = (field, wrappedControl) => {
105105
if (!wrappedControl) return;
106106
const name = field.get('name');
107+
this.childRefs[name] = wrappedControl;
108+
};
107109

108-
this.componentValidate[name] =
109-
wrappedControl.innerWrappedControl?.validate || wrappedControl.validate;
110-
}
110+
getControlRef = field => wrappedControl => {
111+
this.controlRef(field, wrappedControl);
112+
};
111113

112114
handleLocaleChange = val => {
113115
this.setState({ selectedLocale: val });
@@ -152,7 +154,11 @@ export default class ControlPane extends React.Component {
152154
validate = async () => {
153155
this.props.fields.forEach(field => {
154156
if (field.get('widget') === 'hidden') return;
155-
this.componentValidate[field.get('name')]();
157+
const control = this.childRefs[field.get('name')];
158+
const validateFn = control?.innerWrappedControl?.validate ?? control?.validate;
159+
if (validateFn) {
160+
validateFn();
161+
}
156162
});
157163
};
158164

@@ -165,6 +171,14 @@ export default class ControlPane extends React.Component {
165171
}
166172
};
167173

174+
focus(path) {
175+
const [fieldName, ...remainingPath] = path.split('.');
176+
const control = this.childRefs[fieldName];
177+
if (control?.focus) {
178+
control.focus(remainingPath.join('.'));
179+
}
180+
}
181+
168182
render() {
169183
const { collection, entry, fields, fieldsMetaData, fieldsErrors, onChange, onValidate, t } =
170184
this.props;
@@ -227,8 +241,7 @@ export default class ControlPane extends React.Component {
227241
onChange(field, newValue, newMetadata, i18n);
228242
}}
229243
onValidate={onValidate}
230-
processControlRef={this.controlRef.bind(this)}
231-
controlRef={this.controlRef}
244+
controlRef={this.getControlRef(field)}
232245
entry={entry}
233246
collection={collection}
234247
isDisabled={isDuplicate}

packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js

+22-1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export default class Widget extends Component {
4444
fieldsErrors: ImmutablePropTypes.map,
4545
onChange: PropTypes.func.isRequired,
4646
onValidate: PropTypes.func,
47+
controlRef: PropTypes.func,
4748
onOpenMediaLibrary: PropTypes.func.isRequired,
4849
onClearMediaControl: PropTypes.func.isRequired,
4950
onRemoveMediaControl: PropTypes.func.isRequired,
@@ -55,7 +56,6 @@ export default class Widget extends Component {
5556
widget: PropTypes.object.isRequired,
5657
getEditorComponents: PropTypes.func.isRequired,
5758
isFetching: PropTypes.bool,
58-
controlRef: PropTypes.func,
5959
query: PropTypes.func.isRequired,
6060
clearSearch: PropTypes.func.isRequired,
6161
clearFieldErrors: PropTypes.func.isRequired,
@@ -112,8 +112,29 @@ export default class Widget extends Component {
112112
*/
113113
const { shouldComponentUpdate: scu } = this.innerWrappedControl;
114114
this.wrappedControlShouldComponentUpdate = scu && scu.bind(this.innerWrappedControl);
115+
116+
// Call the control ref if provided, passing this Widget instance
117+
if (this.props.controlRef) {
118+
this.props.controlRef(this);
119+
}
115120
};
116121

122+
focus(path) {
123+
// Try widget's custom focus method first
124+
if (this.innerWrappedControl?.focus) {
125+
this.innerWrappedControl.focus(path);
126+
} else {
127+
// Fall back to focusing by ID for simple widgets
128+
const element = document.getElementById(this.props.uniqueFieldId);
129+
element?.focus();
130+
}
131+
// After focusing, ensure the element is visible
132+
const label = document.querySelector(`label[for="${this.props.uniqueFieldId}"]`);
133+
if (label) {
134+
label.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
135+
}
136+
}
137+
117138
getValidateValue = () => {
118139
let value = this.innerWrappedControl?.getValidateValue?.() || this.props.value;
119140
// Convert list input widget value to string for validation test

packages/decap-cms-core/src/components/Editor/EditorInterface.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ class EditorInterface extends Component {
162162
i18nVisible: localStorage.getItem(I18N_VISIBLE) !== 'false',
163163
};
164164

165+
handleFieldClick = path => {
166+
this.controlPaneRef?.focus(path);
167+
};
168+
165169
handleSplitPaneDragStart = () => {
166170
this.setState({ showEventBlocker: true });
167171
};
@@ -298,6 +302,7 @@ class EditorInterface extends Component {
298302
fields={fields}
299303
fieldsMetaData={fieldsMetaData}
300304
locale={leftPanelLocale}
305+
onFieldClick={this.handleFieldClick}
301306
/>
302307
</PreviewPaneContainer>
303308
</StyledSplitPane>
@@ -381,7 +386,7 @@ class EditorInterface extends Component {
381386
title={t('editor.editorInterface.togglePreview')}
382387
/>
383388
)}
384-
{scrollSyncVisible && (
389+
{scrollSyncVisible && !collection.getIn(['editor', 'visualEditing']) && (
385390
<EditorToggle
386391
isActive={scrollSyncEnabled}
387392
onClick={this.handleToggleScrollSync}

packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js

+51-11
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,63 @@ import React from 'react';
33
import { isElement } from 'react-is';
44
import { ScrollSyncPane } from 'react-scroll-sync';
55
import { FrameContextConsumer } from 'react-frame-component';
6+
import { vercelStegaDecode } from '@vercel/stega';
67

78
/**
8-
* We need to create a lightweight component here so that we can access the
9-
* context within the Frame. This allows us to attach the ScrollSyncPane to the
10-
* body.
9+
* PreviewContent renders the preview component and optionally handles visual editing interactions.
10+
* By default it uses scroll sync, but can be configured to use visual editing instead.
1111
*/
1212
class PreviewContent extends React.Component {
13-
render() {
13+
handleClick = e => {
14+
const { previewProps, onFieldClick } = this.props;
15+
const visualEditing = previewProps?.collection?.getIn(['editor', 'visualEditing'], false);
16+
17+
if (!visualEditing) {
18+
return;
19+
}
20+
21+
try {
22+
const text = e.target.textContent;
23+
const decoded = vercelStegaDecode(text);
24+
if (decoded?.decap) {
25+
if (onFieldClick) {
26+
onFieldClick(decoded.decap);
27+
}
28+
}
29+
} catch (err) {
30+
console.log('Visual editing error:', err);
31+
}
32+
};
33+
34+
renderPreview() {
1435
const { previewComponent, previewProps } = this.props;
36+
return (
37+
<div onClick={this.handleClick}>
38+
{isElement(previewComponent)
39+
? React.cloneElement(previewComponent, previewProps)
40+
: React.createElement(previewComponent, previewProps)}
41+
</div>
42+
);
43+
}
44+
45+
render() {
46+
const { previewProps } = this.props;
47+
const visualEditing = previewProps?.collection?.getIn(['editor', 'visualEditing'], false);
48+
const showScrollSync = !visualEditing;
49+
1550
return (
1651
<FrameContextConsumer>
17-
{context => (
18-
<ScrollSyncPane attachTo={context.document.scrollingElement}>
19-
{isElement(previewComponent)
20-
? React.cloneElement(previewComponent, previewProps)
21-
: React.createElement(previewComponent, previewProps)}
22-
</ScrollSyncPane>
23-
)}
52+
{context => {
53+
const preview = this.renderPreview();
54+
if (showScrollSync) {
55+
return (
56+
<ScrollSyncPane attachTo={context.document.scrollingElement}>
57+
{preview}
58+
</ScrollSyncPane>
59+
);
60+
}
61+
return preview;
62+
}}
2463
</FrameContextConsumer>
2564
);
2665
}
@@ -29,6 +68,7 @@ class PreviewContent extends React.Component {
2968
PreviewContent.propTypes = {
3069
previewComponent: PropTypes.func.isRequired,
3170
previewProps: PropTypes.object,
71+
onFieldClick: PropTypes.func,
3272
};
3373

3474
export default PreviewContent;

packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js

+14-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
66
import Frame, { FrameContextConsumer } from 'react-frame-component';
77
import { lengths } from 'decap-cms-ui-default';
88
import { connect } from 'react-redux';
9+
import { encodeEntry } from 'decap-cms-lib-util/src/stega';
910

1011
import {
1112
resolveWidget,
@@ -92,6 +93,7 @@ export class PreviewPane extends React.Component {
9293
if (field.get('meta')) {
9394
value = this.props.entry.getIn(['meta', field.get('name')]);
9495
}
96+
9597
const nestedFields = field.get('fields');
9698
const singleField = field.get('field');
9799
const metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map());
@@ -226,9 +228,18 @@ export class PreviewPane extends React.Component {
226228

227229
this.inferFields();
228230

231+
const visualEditing = collection.getIn(['editor', 'visualEditing'], false);
232+
233+
// Only encode entry data if visual editing is enabled
234+
const previewEntry = visualEditing
235+
? entry.set('data', encodeEntry(entry.get('data'), this.props.fields))
236+
: entry;
237+
229238
const previewProps = {
230239
...this.props,
231-
widgetFor: this.widgetFor,
240+
entry: previewEntry,
241+
widgetFor: (name, fields, values = previewEntry.get('data'), fieldsMetaData) =>
242+
this.widgetFor(name, fields, values, fieldsMetaData),
232243
widgetsFor: this.widgetsFor,
233244
getCollection: this.getCollection,
234245
};
@@ -260,6 +271,7 @@ export class PreviewPane extends React.Component {
260271
return (
261272
<EditorPreviewContent
262273
{...{ previewComponent, previewProps: { ...previewProps, document, window } }}
274+
onFieldClick={this.props.onFieldClick}
263275
/>
264276
);
265277
}}
@@ -276,6 +288,7 @@ PreviewPane.propTypes = {
276288
entry: ImmutablePropTypes.map.isRequired,
277289
fieldsMetaData: ImmutablePropTypes.map.isRequired,
278290
getAsset: PropTypes.func.isRequired,
291+
onFieldClick: PropTypes.func,
279292
};
280293

281294
function mapStateToProps(state) {

0 commit comments

Comments
 (0)