feat: adds indentation controls to rich text

* feat: rich text indent PoC

* fix: new slate version types

* feat: ensures only lowest rich text list is shown as active

* feat: adds icons for indentation

* docs: adds indent to rich text
This commit is contained in:
James Mikrut
2022-01-12 14:16:05 -05:00
committed by GitHub
parent baa6258bba
commit 7df50f9bf9
24 changed files with 379 additions and 33 deletions

View File

@@ -57,6 +57,7 @@ The default `elements` available in Payload are:
- `link`
- `ol`
- `ul`
- `indent`
- [`relationship`](#relationship-element)
- [`upload`](#upload-element)

View File

@@ -166,10 +166,10 @@
"sass": "^1.42.0",
"sass-loader": "^10.1.0",
"sharp": "^0.29.3",
"slate": "^0.66.2",
"slate": "0.72.3",
"slate-history": "^0.66.0",
"slate-hyperscript": "^0.66.0",
"slate-react": "^0.66.4",
"slate-react": "^0.72.1",
"style-loader": "^2.0.0",
"terser-webpack-plugin": "^5.0.3",
"ts-essentials": "^7.0.1",

View File

@@ -17,7 +17,7 @@ import defaultValue from '../../../../../fields/richText/defaultValue';
import FieldTypeGutter from '../../FieldTypeGutter';
import FieldDescription from '../../FieldDescription';
import withHTML from './plugins/withHTML';
import { Props } from './types';
import { Props, BlurSelectionEditor } from './types';
import { RichTextElement, RichTextLeaf } from '../../../../../fields/config/types';
import listTypes from './elements/listTypes';
import mergeCustomFunctions from './mergeCustomFunctions';
@@ -25,7 +25,7 @@ import withEnterBreakOut from './plugins/withEnterBreakOut';
import './index.scss';
const defaultElements: RichTextElement[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'link', 'relationship', 'upload'];
const defaultElements: RichTextElement[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'indent', 'link', 'relationship', 'upload'];
const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline', 'strikethrough', 'code'];
const baseClass = 'rich-text';
@@ -35,7 +35,7 @@ type CustomElement = { type: string; children: CustomText[] }
declare module 'slate' {
interface CustomTypes {
Editor: BaseEditor & ReactEditor & HistoryEditor
Editor: BaseEditor & ReactEditor & HistoryEditor & BlurSelectionEditor
Element: CustomElement
Text: CustomText
}
@@ -280,12 +280,11 @@ const RichText: React.FC<Props> = (props) => {
if (Text.isText(selectedLeaf) && String(selectedLeaf.text).length === editor.selection.anchor.offset) {
Transforms.insertNodes(editor, {
type: 'p',
children: [{ text: '' }],
text: '',
});
} else {
Transforms.splitNodes(editor);
Transforms.setNodes(editor, { type: 'p' });
Transforms.setNodes(editor, {});
}
}
}
@@ -303,7 +302,7 @@ const RichText: React.FC<Props> = (props) => {
split: true,
});
Transforms.setNodes(editor, { type: 'p' });
Transforms.setNodes(editor, {});
}
} else if (editor.isVoid(selectedElement)) {
Transforms.removeNodes(editor);

View File

@@ -5,4 +5,8 @@
width: base(.75);
height: base(.75);
}
&--disabled {
opacity: .4;
}
}

View File

@@ -6,7 +6,7 @@ import { ButtonProps } from './types';
import '../buttons.scss';
const baseClass = 'rich-text__button';
export const baseClass = 'rich-text__button';
const ElementButton: React.FC<ButtonProps> = ({ format, children, onClick, className }) => {
const editor = useSlate();

View File

@@ -0,0 +1,34 @@
import React, { useCallback } from 'react';
import { useSlate } from 'slate-react';
import isListActive from './isListActive';
import toggleElement from './toggle';
import { ButtonProps } from './types';
import '../buttons.scss';
export const baseClass = 'rich-text__button';
const ListButton: React.FC<ButtonProps> = ({ format, children, onClick, className }) => {
const editor = useSlate();
const defaultOnClick = useCallback((event) => {
event.preventDefault();
toggleElement(editor, format);
}, [editor, format]);
return (
<button
type="button"
className={[
baseClass,
className,
isListActive(editor, format) && `${baseClass}__button--active`,
].filter(Boolean).join(' ')}
onClick={onClick || defaultOnClick}
>
{children}
</button>
);
};
export default ListButton;

View File

@@ -0,0 +1,100 @@
import React, { useCallback } from 'react';
import { useSlate, ReactEditor } from 'slate-react';
import { Editor, Element, Transforms } from 'slate';
import IndentLeft from '../../../../../icons/IndentLeft';
import IndentRight from '../../../../../icons/IndentRight';
import { baseClass } from '../Button';
import isElementActive from '../isActive';
import listTypes from '../listTypes';
const indentType = 'indent';
const IndentWithPadding = ({ attributes, children }) => (
<div
style={{ paddingLeft: 25 }}
{...attributes}
>
{children}
</div>
);
const indent = {
Button: () => {
const editor = useSlate();
const handleIndent = useCallback((e, dir) => {
e.preventDefault();
if (editor.blurSelection) {
Transforms.select(editor, editor.blurSelection);
}
if (dir === 'left') {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && [indentType, ...listTypes].includes(n.type),
split: true,
mode: 'lowest',
});
if (isElementActive(editor, 'li')) {
const [, parentLocation] = Editor.parent(editor, editor.selection);
const [, parentDepth] = parentLocation;
if (parentDepth > 0 || parentDepth === 0) {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && n.type === 'li',
split: true,
mode: 'lowest',
});
} else {
Transforms.unsetNodes(editor, ['type']);
}
}
}
if (dir === 'right') {
const isCurrentlyOL = isElementActive(editor, 'ol');
const isCurrentlyUL = isElementActive(editor, 'ul');
if (isCurrentlyOL || isCurrentlyUL) {
Transforms.wrapNodes(editor, {
type: 'li',
children: [],
});
Transforms.wrapNodes(editor, { type: isCurrentlyOL ? 'ol' : 'ul', children: [{ text: ' ' }] });
Transforms.setNodes(editor, { type: 'li' });
} else {
Transforms.wrapNodes(editor, { type: indentType, children: [] });
}
}
ReactEditor.focus(editor);
}, [editor]);
const canDeIndent = isElementActive(editor, 'li') || isElementActive(editor, indentType);
return (
<React.Fragment>
<button
type="button"
className={[
baseClass,
!canDeIndent && `${baseClass}--disabled`,
].filter(Boolean).join(' ')}
onClick={canDeIndent ? (e) => handleIndent(e, 'left') : undefined}
>
<IndentLeft />
</button>
<button
type="button"
className={baseClass}
onClick={(e) => handleIndent(e, 'right')}
>
<IndentRight />
</button>
</React.Fragment>
);
},
Element: IndentWithPadding,
};
export default indent;

View File

@@ -8,10 +8,11 @@ import link from './link';
import ol from './ol';
import ul from './ul';
import li from './li';
import indent from './indent';
import relationship from './relationship';
import upload from './upload';
export default {
const elements = {
h1,
h2,
h3,
@@ -22,6 +23,9 @@ export default {
ol,
ul,
li,
indent,
relationship,
upload,
};
export default elements;

View File

@@ -1,9 +1,12 @@
import { Editor, Element } from 'slate';
const isElementActive = (editor, format) => {
const [match] = Editor.nodes(editor, {
match: (n) => Element.isElement(n) && n.type === format,
});
if (!editor.selection) return false;
const [match] = Array.from(Editor.nodes(editor, {
at: Editor.unhangRange(editor, editor.selection),
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === format,
}));
return !!match;
};

View File

@@ -0,0 +1,24 @@
import { Editor, Element } from 'slate';
const isListActive = (editor: Editor, format: string): boolean => {
if (!editor.selection
// If focus or anchor is at root of editor,
// Return false - as Editor.parent will fail
|| editor.selection.focus.path[1] === 0
|| editor.selection.anchor.path[1] === 0
) return false;
const parentLI = Editor.parent(editor, editor.selection);
if (parentLI[1].length > 0) {
const ancestor = Editor.above(editor, {
at: parentLI[1],
});
return Element.isElement(ancestor[0]) && ancestor[0].type === format;
}
return false;
};
export default isListActive;

View File

@@ -1,8 +1,19 @@
import React from 'react';
import listTypes from '../listTypes';
const LI = ({ attributes, children }) => (
<li {...attributes}>{children}</li>
);
const LI = (props) => {
const { attributes, element, children } = props;
const disableListStyle = element.children.length === 1 && listTypes.includes(element.children?.[0]?.type);
return (
<li
style={{ listStyle: disableListStyle ? 'none' : undefined }}
{...attributes}
>
{children}
</li>
);
};
export default {
Element: LI,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ElementButton from '../Button';
import ListButton from '../ListButton';
import OLIcon from '../../../../../icons/OrderedList';
const OL = ({ attributes, children }) => (
@@ -8,9 +8,9 @@ const OL = ({ attributes, children }) => (
const ol = {
Button: () => (
<ElementButton format="ol">
<ListButton format="ol">
<OLIcon />
</ElementButton>
</ListButton>
),
Element: OL,
};

View File

@@ -10,7 +10,7 @@ const toggleElement = (editor, format) => {
let type = format;
if (isActive) {
type = 'p';
type = undefined;
} else if (isList) {
type = 'li';
}
@@ -22,6 +22,7 @@ const toggleElement = (editor, format) => {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && listTypes.includes(n.type as string),
split: true,
mode: 'lowest',
});
Transforms.setNodes(editor, { type });

View File

@@ -1,6 +1,6 @@
import React from 'react';
import ElementButton from '../Button';
import ULIcon from '../../../../../icons/UnorderedList';
import ListButton from '../ListButton';
const UL = ({ attributes, children }) => (
<ul {...attributes}>{children}</ul>
@@ -8,9 +8,9 @@ const UL = ({ attributes, children }) => (
const ul = {
Button: () => (
<ElementButton format="ul">
<ListButton format="ul">
<ULIcon />
</ElementButton>
</ListButton>
),
Element: UL,
};

View File

@@ -1,5 +1,10 @@
import { BaseEditor, Selection } from 'slate';
import { RichTextField } from '../../../../../fields/config/types';
export type Props = Omit<RichTextField, 'type'> & {
path?: string
}
export interface BlurSelectionEditor extends BaseEditor {
blurSelection?: Selection
}

View File

@@ -0,0 +1,16 @@
@import '../../../scss/styles';
.icon--indent-left {
height: $baseline;
width: $baseline;
.stroke {
fill: none;
stroke: $color-dark-gray;
stroke-width: $style-stroke-width-m;
}
.fill {
fill: $color-dark-gray;
}
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import './index.scss';
const IndentLeft: React.FC = () => (
<svg
className="icon icon--indent-left"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 25 25"
>
<path
d="M16.005 9.61502L21.005 13.1864L21.005 6.04361L16.005 9.61502Z"
className="fill"
/>
<rect
x="5"
y="5.68199"
width="9.0675"
height="2.15625"
className="fill"
/>
<rect
x="5"
y="11.4738"
width="9.0675"
height="2.15625"
className="fill"
/>
<rect
x="5"
y="17.2656"
width="16.005"
height="2.15625"
className="fill"
/>
</svg>
);
export default IndentLeft;

View File

@@ -0,0 +1,16 @@
@import '../../../scss/styles';
.icon--indent-right {
height: $baseline;
width: $baseline;
.stroke {
fill: none;
stroke: $color-dark-gray;
stroke-width: $style-stroke-width-m;
}
.fill {
fill: $color-dark-gray;
}
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import './index.scss';
const IndentRight: React.FC = () => (
<svg
className="icon icon--indent-right"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 25 25"
>
<path
d="M10 9.61502L5 6.04361L5 13.1864L10 9.61502Z"
fill="#333333"
/>
<rect
x="11.9375"
y="5.68199"
width="9.0675"
height="2.15625"
className="fill"
/>
<rect
x="11.9375"
y="11.4738"
width="9.0675"
height="2.15625"
className="fill"
/>
<rect
x="5"
y="17.2656"
width="16.005"
height="2.15625"
className="fill"
/>
</svg>
);
export default IndentRight;

View File

@@ -0,0 +1,12 @@
@import '../../../scss/styles';
.icon--swap {
height: $baseline;
width: $baseline;
.stroke {
fill: none;
stroke: $color-dark-gray;
stroke-width: $style-stroke-width-m;
}
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import './index.scss';
const Swap: React.FC = () => (
<svg
className="icon icon--swap"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 25 25"
>
<path
d="M9.84631 4.78679L6.00004 8.63306L9.84631 12.4793"
className="stroke"
/>
<path
d="M15.1537 20.1059L19 16.2596L15.1537 12.4133"
className="stroke"
/>
<line
x1="7"
y1="8.7013"
x2="15"
y2="8.7013"
stroke="#333333"
className="stroke"
/>
<line
x1="18"
y1="16.1195"
x2="10"
y2="16.1195"
className="stroke"
/>
</svg>
);
export default Swap;

View File

@@ -213,7 +213,7 @@ export type RichTextCustomLeaf = {
plugins?: RichTextPlugin[]
}
export type RichTextElement = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote' | 'ul' | 'ol' | 'link' | 'relationship' | 'upload' | RichTextCustomElement;
export type RichTextElement = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'blockquote' | 'ul' | 'ol' | 'link' | 'relationship' | 'upload' | 'indent' | RichTextCustomElement;
export type RichTextLeaf = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code' | RichTextCustomLeaf;
export type RichTextField = FieldBase & {

View File

@@ -1,3 +1,4 @@
export default [{
type: 'p',
children: [{ text: '' }],
}];

View File

@@ -11794,10 +11794,10 @@ slate-hyperscript@^0.66.0:
dependencies:
is-plain-object "^5.0.0"
slate-react@^0.66.4:
version "0.66.7"
resolved "https://registry.npmjs.org/slate-react/-/slate-react-0.66.7.tgz#033823dac8612e040094e7b8d942de6639b2e7de"
integrity sha512-M0MGCnANdjRZCKGY9cvqHBR/WJ+/4SMIvBvEx4UJ5ycx9uNkDVPdpsyvwDKOpmsJuzZ1DH+34YrpxT7RnQyp1Q==
slate-react@^0.72.1:
version "0.72.1"
resolved "https://registry.npmjs.org/slate-react/-/slate-react-0.72.1.tgz#a18917625fa9ec87ae137b6f78bb36d04cdb732b"
integrity sha512-H7bCem0xE0PHfaoWOcz18cQ0SZ/oTljAiEH4ygqaeYUjPOid6kAEJ8n28Psbp5g4njIgHTnHfVJGuPiTcKtKeA==
dependencies:
"@types/is-hotkey" "^0.1.1"
"@types/lodash" "^4.14.149"
@@ -11808,10 +11808,10 @@ slate-react@^0.66.4:
scroll-into-view-if-needed "^2.2.20"
tiny-invariant "1.0.6"
slate@^0.66.2:
version "0.66.5"
resolved "https://registry.npmjs.org/slate/-/slate-0.66.5.tgz#bdd93a4422891341dd18a31ff7810f5cae60e40a"
integrity sha512-bG03uEKIm/gS6jQarKSNbHn2anemOON2vnSI3VGRd7MJJU5Yiwmutze0yHNO9uZwDLTB+LeDQYZeGu1ACWT0VA==
slate@0.72.3:
version "0.72.3"
resolved "https://registry.npmjs.org/slate/-/slate-0.72.3.tgz#c74eb85133b975b2d44d219462c127cdb992e76b"
integrity sha512-ALsYQHKTN4rC+iHnOJzV+aC4AHdhoPkBWrfEK3W/LbXOzPrR+wL80a66OZiYg9Xb0QeGzlLSGdOOFQd2ix9Wmg==
dependencies:
immer "^9.0.6"
is-plain-object "^5.0.0"