diff --git a/dev-test/config.yml b/dev-test/config.yml index d9bf6182a7cc..592ffbed8b2f 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -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 @@ -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.' } diff --git a/package-lock.json b/package-lock.json index 9e1197d65412..5c1729a629cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7718,6 +7718,11 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vercel/stega": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@vercel/stega/-/stega-0.1.2.tgz", + "integrity": "sha512-P7mafQXjkrsoyTRppnt0N21udKS9wUmLXHRyP9saLXLHw32j/FgUJ3FscSWgvSqRs4cj7wKZtwqJEvWJ2jbGmA==" + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", "dev": true, @@ -33419,6 +33424,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", diff --git a/packages/decap-cms-core/package.json b/packages/decap-cms-core/package.json index 59654f5a0fe4..87f3566d17cf 100644 --- a/packages/decap-cms-core/package.json +++ b/packages/decap-cms-core/package.json @@ -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", diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index 0dab96fba267..dae2d3d3a241 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -171,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; diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js index 21cd12c7ee9e..96c530d06232 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js @@ -119,6 +119,22 @@ export default class Widget extends Component { } }; + 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 diff --git a/packages/decap-cms-core/src/components/Editor/EditorInterface.js b/packages/decap-cms-core/src/components/Editor/EditorInterface.js index 7fb49ae0efac..cad473866aa2 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorInterface.js +++ b/packages/decap-cms-core/src/components/Editor/EditorInterface.js @@ -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 }); }; @@ -298,6 +302,7 @@ class EditorInterface extends Component { fields={fields} fieldsMetaData={fieldsMetaData} locale={leftPanelLocale} + onFieldClick={this.handleFieldClick} /> @@ -381,7 +386,7 @@ class EditorInterface extends Component { title={t('editor.editorInterface.togglePreview')} /> )} - {scrollSyncVisible && ( + {scrollSyncVisible && !collection.getIn(['editor', 'visualEditing']) && ( { + 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 ( +
+ {isElement(previewComponent) + ? React.cloneElement(previewComponent, previewProps) + : React.createElement(previewComponent, previewProps)} +
+ ); + } + + render() { + const { previewProps } = this.props; + const visualEditing = previewProps?.collection?.getIn(['editor', 'visualEditing'], false); + const showScrollSync = !visualEditing; + return ( - {context => ( - - {isElement(previewComponent) - ? React.cloneElement(previewComponent, previewProps) - : React.createElement(previewComponent, previewProps)} - - )} + {context => { + const preview = this.renderPreview(); + if (showScrollSync) { + return ( + + {preview} + + ); + } + return preview; + }} ); } @@ -29,6 +68,7 @@ class PreviewContent extends React.Component { PreviewContent.propTypes = { previewComponent: PropTypes.func.isRequired, previewProps: PropTypes.object, + onFieldClick: PropTypes.func, }; export default PreviewContent; diff --git a/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js b/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js index b9feb9887238..c35fc642fe39 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +++ b/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js @@ -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, @@ -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()); @@ -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, }; @@ -260,6 +271,7 @@ export class PreviewPane extends React.Component { return ( ); }} @@ -276,6 +288,7 @@ PreviewPane.propTypes = { entry: ImmutablePropTypes.map.isRequired, fieldsMetaData: ImmutablePropTypes.map.isRequired, getAsset: PropTypes.func.isRequired, + onFieldClick: PropTypes.func, }; function mapStateToProps(state) { diff --git a/packages/decap-cms-lib-util/src/stega.ts b/packages/decap-cms-lib-util/src/stega.ts new file mode 100644 index 000000000000..099b86e6db03 --- /dev/null +++ b/packages/decap-cms-lib-util/src/stega.ts @@ -0,0 +1,134 @@ +import { vercelStegaEncode } from '@vercel/stega'; + +import { isImmutableMap, isImmutableList } from './types'; + +import type { Map as ImmutableMap, List } from 'immutable'; +import type { CmsField } from 'decap-cms-core'; + +/** + * Context passed to encode functions, containing the current state of the encoding process + */ +interface EncodeContext { + fields: CmsField[]; // Available CMS fields at current level + path: string; // Path to current value in object tree + visit: (value: unknown, fields: CmsField[], path: string) => unknown; // Visitor for recursive traversal +} + +/** + * Get the fields that should be used for encoding nested values + */ +function getNestedFields(f?: CmsField): CmsField[] { + if (f) { + if ('types' in f) { + return f.types ?? []; + } + if ('fields' in f) { + return f.fields ?? []; + } + if ('field' in f) { + return f.field ? [f.field] : []; + } + return [f]; + } + return []; +} + +/** + * Encode a string value by appending steganographic data + * For markdown fields, encode each paragraph separately + */ +function encodeString(value: string, { fields, path }: EncodeContext): string { + const stega = vercelStegaEncode({ decap: path }); + const isMarkdown = fields[0]?.widget === 'markdown'; + + if (isMarkdown && value.includes('\n\n')) { + const blocks = value.split(/(\n\n+)/); + return blocks.map(block => (block.trim() ? block + stega : block)).join(''); + } + return value + stega; +} + +/** + * Encode a list of values, handling both simple values and nested objects/lists + * For typed lists, use the type field to determine which fields to use + */ +function encodeList(list: List, ctx: EncodeContext): List { + let newList = list; + for (let i = 0; i < newList.size; i++) { + const item = newList.get(i); + if (isImmutableMap(item)) { + const itemType = item.get('type'); + if (typeof itemType === 'string') { + // For typed items, look up fields based on type + const field = ctx.fields.find(f => f.name === itemType); + const newItem = ctx.visit(item, getNestedFields(field), `${ctx.path}.${i}`); + newList = newList.set(i, newItem); + } else { + // For untyped items, use current fields + const newItem = ctx.visit(item, ctx.fields, `${ctx.path}.${i}`); + newList = newList.set(i, newItem); + } + } else { + // For simple values, use first field if available + const field = ctx.fields[0]; + const newItem = ctx.visit(item, field ? [field] : [], `${ctx.path}.${i}`); + if (newItem !== item) { + newList = newList.set(i, newItem); + } + } + } + return newList; +} + +/** + * Encode a map of values, looking up the appropriate field for each key + * and recursively encoding nested values + */ +function encodeMap( + map: ImmutableMap, + ctx: EncodeContext, +): ImmutableMap { + let newMap = map; + for (const [key, val] of newMap.entrySeq().toArray()) { + const field = ctx.fields.find(f => f.name === key); + if (field) { + const fields = getNestedFields(field); + const newVal = ctx.visit(val, fields, ctx.path ? `${ctx.path}.${key}` : key); + if (newVal !== val) { + newMap = newMap.set(key, newVal); + } + } + } + return newMap; +} + +/** + * Main entry point for encoding steganographic data into entry values + * Uses a visitor pattern with caching to handle recursive structures + */ +export function encodeEntry(value: unknown, fields: List>) { + const plainFields = fields.toJS() as CmsField[]; + const cache = new Map(); + + function visit(value: unknown, fields: CmsField[], path = '') { + const cached = cache.get(path); + if (cached === value) return value; + + const ctx: EncodeContext = { fields, path, visit }; + let result; + if (isImmutableList(value)) { + result = encodeList(value, ctx); + } else if (isImmutableMap(value)) { + result = encodeMap(value, ctx); + } else if (typeof value === 'string') { + result = encodeString(value, ctx); + } else { + result = value; + } + + cache.set(path, result); + return result; + } + + return visit(value, plainFields); +} diff --git a/packages/decap-cms-lib-util/src/types.ts b/packages/decap-cms-lib-util/src/types.ts new file mode 100644 index 000000000000..5caf8822565c --- /dev/null +++ b/packages/decap-cms-lib-util/src/types.ts @@ -0,0 +1,9 @@ +import { Map as ImmutableMap, List } from 'immutable'; + +export function isImmutableMap(value: unknown): value is ImmutableMap { + return ImmutableMap.isMap(value); +} + +export function isImmutableList(value: unknown): value is List { + return List.isList(value); +} diff --git a/packages/decap-cms-widget-list/src/ListControl.js b/packages/decap-cms-widget-list/src/ListControl.js index f0270c3afb25..277e5a0d5718 100644 --- a/packages/decap-cms-widget-list/src/ListControl.js +++ b/packages/decap-cms-widget-list/src/ListControl.js @@ -595,6 +595,34 @@ export default class ListControl extends React.Component { } }; + focus(path) { + const [index, ...remainingPath] = path.split('.'); + + if (this.state.listCollapsed || this.state.itemsCollapsed[index]) { + const newItemsCollapsed = [...this.state.itemsCollapsed]; + newItemsCollapsed[index] = false; + this.setState( + { + listCollapsed: false, + itemsCollapsed: newItemsCollapsed, + }, + () => { + const key = this.state.keys[index]; + const control = this.childRefs[key]; + if (control?.focus) { + control.focus(remainingPath.join('.')); + } + }, + ); + } else { + const key = this.state.keys[index]; + const control = this.childRefs[key]; + if (control?.focus) { + control.focus(remainingPath.join('.')); + } + } + } + // eslint-disable-next-line react/display-name renderItem = (item, index) => { const { diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/RawEditor.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/RawEditor.js index d9478ddb0a66..6a99b9c8481d 100644 --- a/packages/decap-cms-widget-markdown/src/MarkdownControl/RawEditor.js +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/RawEditor.js @@ -43,8 +43,9 @@ function RawEditor(props) { useEffect(() => { if (props.pendingFocus) { ReactEditor.focus(editor); + props.pendingFocus(); } - }, []); + }, [props.pendingFocus]); function handleToggleMode() { props.onMode('rich_text'); diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/VisualEditor.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/VisualEditor.js index d73fbaa7ff60..860939fbb979 100644 --- a/packages/decap-cms-widget-markdown/src/MarkdownControl/VisualEditor.js +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/VisualEditor.js @@ -135,8 +135,9 @@ function Editor(props) { useEffect(() => { if (props.pendingFocus) { ReactEditor.focus(editor); + props.pendingFocus(); } - }, []); + }, [props.pendingFocus]); function handleMarkClick(format) { ReactEditor.focus(editor); diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/index.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/index.js index d89b199872f5..5831f88a3c2e 100644 --- a/packages/decap-cms-widget-markdown/src/MarkdownControl/index.js +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/index.js @@ -67,6 +67,10 @@ export default class MarkdownControl extends React.Component { getAllowedModes = () => this.props.field.get('modes', List(['rich_text', 'raw'])).toArray(); + focus() { + this.setState({ pendingFocus: true }); + } + render() { const { onChange, diff --git a/packages/decap-cms-widget-object/src/ObjectControl.js b/packages/decap-cms-widget-object/src/ObjectControl.js index 7e61a368697b..0d717f3e287c 100644 --- a/packages/decap-cms-widget-object/src/ObjectControl.js +++ b/packages/decap-cms-widget-object/src/ObjectControl.js @@ -135,6 +135,26 @@ export default class ObjectControl extends React.Component { this.setState({ collapsed: !this.state.collapsed }); }; + focus(path) { + if (this.state.collapsed) { + this.setState({ collapsed: false }, () => { + if (path) { + const [fieldName, ...remainingPath] = path.split('.'); + const field = this.childRefs[fieldName]; + if (field?.focus) { + field.focus(remainingPath.join('.')); + } + } + }); + } else if (path) { + const [fieldName, ...remainingPath] = path.split('.'); + const field = this.childRefs[fieldName]; + if (field?.focus) { + field.focus(remainingPath.join('.')); + } + } + } + renderFields = (multiFields, singleField) => { if (multiFields) { return multiFields.map((f, idx) => this.controlFor(f, idx));