Skip to content

Commit

Permalink
feat: add click-to-edit
Browse files Browse the repository at this point in the history
  • Loading branch information
fgnass committed Jan 15, 2025
1 parent a7f2f5f commit 42598db
Show file tree
Hide file tree
Showing 15 changed files with 306 additions and 16 deletions.
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 @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
134 changes: 134 additions & 0 deletions packages/decap-cms-lib-util/src/stega.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, ctx: EncodeContext): List<unknown> {
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<string, unknown>,
ctx: EncodeContext,
): ImmutableMap<string, unknown> {
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<ImmutableMap<string, unknown>>) {
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);
}
9 changes: 9 additions & 0 deletions packages/decap-cms-lib-util/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Map as ImmutableMap, List } from 'immutable';

export function isImmutableMap(value: unknown): value is ImmutableMap<string, unknown> {
return ImmutableMap.isMap(value);
}

export function isImmutableList(value: unknown): value is List<unknown> {
return List.isList(value);
}
Loading

0 comments on commit 42598db

Please sign in to comment.