Compare commits

...

13 Commits

Author SHA1 Message Date
Germán Jabloñski
799aadf54d don't add false booleans 2024-12-04 14:01:01 -03:00
Germán Jabloñski
8a941b3842 add boolean option. Improve json serialization 2024-12-04 13:48:55 -03:00
Germán Jabloñski
659744039f save 2024-12-04 12:21:48 -03:00
Germán Jabloñski
05422c9ba0 rename index to utils 2024-12-04 11:21:49 -03:00
Germán Jabloñski
7259e457d6 save WIP 2024-12-04 11:21:31 -03:00
Germán Jabloñski
643e333659 save 2024-12-04 00:06:35 -03:00
Germán Jabloñski
e8d8bb04c3 save WIP 2024-12-02 13:58:11 -03:00
Germán Jabloñski
b6046a3100 custom textnode 2024-11-28 23:28:30 -03:00
Germán Jabloñski
2e4911a642 Merge branch 'main' into feat/TextAttributeFeature 2024-11-27 17:30:40 -03:00
Germán Jabloñski
30a0e192fa save WIP 2024-11-26 11:49:11 -03:00
Germán Jabloñski
affeea187d add perf improvement 2024-11-25 11:51:45 -03:00
Germán Jabloñski
18daa90e69 finish enabledFormat 2024-11-25 11:35:40 -03:00
Germán Jabloñski
aafc628650 docs: fix description of previousValue 2024-11-22 18:17:14 -03:00
17 changed files with 510 additions and 37 deletions

View File

@@ -15,6 +15,7 @@ export { StrikethroughFeatureClient } from '../../features/format/strikethrough/
export { SubscriptFeatureClient } from '../../features/format/subscript/feature.client.js'
export { SuperscriptFeatureClient } from '../../features/format/superscript/feature.client.js'
export { UnderlineFeatureClient } from '../../features/format/underline/feature.client.js'
export { TextColorFeatureClient } from '../../features/textColor/feature.client.js'
export { HeadingFeatureClient } from '../../features/heading/client/index.js'
export { HorizontalRuleFeatureClient } from '../../features/horizontalRule/client/index.js'
export { IndentFeatureClient } from '../../features/indent/client/index.js'
@@ -88,7 +89,7 @@ export {
export { ENABLE_SLASH_MENU_COMMAND } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/index.js'
export { getEnabledNodes } from '../../lexical/nodes/index.js'
export { getEnabledNodes } from '../../lexical/nodes/utils.js'
export {
$createUploadNode,

View File

@@ -6,7 +6,7 @@ import { $parseSerializedNode } from 'lexical'
import type { NodeWithHooks } from '../../typesServer.js'
import { getEnabledNodesFromServerNodes } from '../../../lexical/nodes/index.js'
import { getEnabledNodesFromServerNodes } from '../../../lexical/nodes/utils.js'
import {
$convertToMarkdownString,
type MultilineElementTransformer,

View File

@@ -0,0 +1,101 @@
'use client'
import { $getSelection, $isRangeSelection } from 'lexical'
import type { ToolbarDropdownGroup, ToolbarGroup } from '../toolbars/types.js'
import type { TextColorFeatureProps } from './feature.server.js'
import { $mutateSelectedTextNodes } from '../../lexical/nodes/utils.js'
import { BgColorIcon } from '../../lexical/ui/icons/BgColor/index.js'
import { TextColorIcon } from '../../lexical/ui/icons/TextColor/index.js'
import './index.scss'
import { createClientFeature } from '../../utilities/createClientFeature.js'
const defaultColors = [
{ classSuffix: 'red', label: 'Red' },
{ classSuffix: 'blue', label: 'Blue' },
{ classSuffix: 'green', label: 'Green' },
{ classSuffix: 'yellow', label: 'Yellow' },
{ classSuffix: 'purple', label: 'Purple' },
{ classSuffix: 'pink', label: 'Pink' },
{ classSuffix: 'orange', label: 'Orange' },
{ classSuffix: 'gray', label: 'Gray' },
]
const toolbarGroups = (props: TextColorFeatureProps): ToolbarGroup[] => {
// TO-DO: I would rather have defaultBgColors but no defaultColors
const { bgColors = defaultColors, colors = defaultColors } = props
const colorItems: ToolbarDropdownGroup['items'] | undefined = colors?.map(({ classSuffix }) => {
return {
ChildComponent: TextColorIcon,
key: `color-${classSuffix}`,
label: classSuffix,
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return
}
$mutateSelectedTextNodes(selection, (textNode) => {
textNode.mutateClasses((classes) => {
classes['color'] = classSuffix
delete classes['bgColor'] // TO-DECIDE: this should be like this be default? configurable in props?
})
})
})
},
order: 1,
}
})
const bgColorItems: ToolbarDropdownGroup['items'] = bgColors.map(({ classSuffix }) => {
return {
ChildComponent: TextColorIcon,
key: `bg-color-${classSuffix}`,
label: classSuffix,
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return
}
$mutateSelectedTextNodes(selection, (textNode) => {
textNode.mutateClasses((classes) => {
// TO-DECIDE: prefix should be only bg?
classes['bg-color'] = classSuffix
delete classes['color']
})
})
})
},
order: 1,
}
})
return [
{
type: 'dropdown' as const,
ChildComponent: TextColorIcon,
items: colorItems,
key: 'color',
order: 30,
},
{
type: 'dropdown' as const,
ChildComponent: BgColorIcon,
items: bgColorItems,
key: 'bg-color',
order: 30,
},
]
}
export const TextColorFeatureClient = createClientFeature<TextColorFeatureProps>(({ props }) => {
return {
toolbarFixed: {
groups: toolbarGroups(props),
},
toolbarInline: {
groups: toolbarGroups(props),
},
}
})

View File

@@ -0,0 +1,26 @@
import { createServerFeature } from '../../index.js'
export type TextColorFeatureProps = {
bgColors?: { classSuffix: string; label: string }[]
/**
* TO-DO: see i18n patterns. The user should provide it in label
*/
colors?: { classSuffix: string; label: string }[]
}
/**
*
*/
export const TextColorFeature = createServerFeature<
TextColorFeatureProps,
TextColorFeatureProps,
TextColorFeatureProps
>({
feature: ({ props }) => {
return {
ClientFeature: '@payloadcms/richtext-lexical/client#TextColorFeatureClient',
clientFeatureProps: props,
}
},
key: 'textColor',
})

View File

@@ -0,0 +1,23 @@
.color-green {
color: green;
}
.color-blue {
color: blue;
}
.color-red {
color: red;
}
.bg-color-green {
background-color: green;
}
.bg-color-blue {
background-color: blue;
}
.bg-color-red {
background-color: red;
}

View File

@@ -848,22 +848,22 @@ export { UnderlineFeature } from './features/format/underline/feature.server.js'
export { HeadingFeature, type HeadingFeatureProps } from './features/heading/server/index.js'
export { HorizontalRuleFeature } from './features/horizontalRule/server/index.js'
export { IndentFeature } from './features/indent/server/index.js'
export { AutoLinkNode } from './features/link/nodes/AutoLinkNode.js'
export { LinkNode } from './features/link/nodes/LinkNode.js'
export type { LinkFields } from './features/link/nodes/types.js'
export { LinkFeature, type LinkFeatureServerProps } from './features/link/server/index.js'
export { ChecklistFeature } from './features/lists/checklist/server/index.js'
export { OrderedListFeature } from './features/lists/orderedList/server/index.js'
export { UnorderedListFeature } from './features/lists/unorderedList/server/index.js'
export type {
SlateNode,
SlateNodeConverter,
} from './features/migrations/slateToLexical/converter/types.js'
export { ParagraphFeature } from './features/paragraph/server/index.js'
export {
RelationshipFeature,
type RelationshipFeatureProps,
@@ -872,6 +872,7 @@ export {
type RelationshipData,
RelationshipServerNode,
} from './features/relationship/server/nodes/RelationshipNode.js'
export { TextColorFeature } from './features/textColor/feature.server.js'
export { FixedToolbarFeature } from './features/toolbars/fixed/server/index.js'
export { InlineToolbarFeature } from './features/toolbars/inline/server/index.js'
@@ -938,7 +939,7 @@ export type {
SanitizedServerEditorConfig,
ServerEditorConfig,
} from './lexical/config/types.js'
export { getEnabledNodes, getEnabledNodesFromServerNodes } from './lexical/nodes/index.js'
export { getEnabledNodes, getEnabledNodesFromServerNodes } from './lexical/nodes/utils.js'
export type { AdapterProps }
export type {

View File

@@ -15,7 +15,7 @@ import {
useEditorConfigContext,
} from './config/client/EditorConfigProvider.js'
import { LexicalEditor as LexicalEditorComponent } from './LexicalEditor.js'
import { getEnabledNodes } from './nodes/index.js'
import { getEnabledNodes } from './nodes/utils.js'
export type LexicalProviderProps = {
composerKey: string

View File

@@ -2,6 +2,8 @@
import type { EditorConfig as LexicalEditorConfig } from 'lexical'
import { TextNode as LexicalTextNode } from 'lexical'
import type {
ResolvedClientFeatureMap,
SanitizedClientFeatures,
@@ -9,6 +11,8 @@ import type {
import type { LexicalFieldAdminProps } from '../../../types.js'
import type { SanitizedClientEditorConfig } from '../types.js'
import { TextNode } from '../../nodes/TextNode.js'
export const sanitizeClientFeatures = (
features: ResolvedClientFeatureMap,
): SanitizedClientFeatures => {
@@ -16,7 +20,10 @@ export const sanitizeClientFeatures = (
enabledFeatures: [],
enabledFormats: [],
markdownTransformers: [],
nodes: [],
nodes: [
TextNode,
{ replace: LexicalTextNode, with: (node) => new TextNode(node.__text), withKlass: TextNode },
],
plugins: [],
providers: [],
slashMenu: {

View File

@@ -0,0 +1,76 @@
import type { EditorConfig, SerializedTextNode as SerializedLexicalTextNode } from 'lexical'
import { TextNode as LexicalTextNode } from 'lexical'
type MutableClasses = { [classSuffix: string]: boolean | string }
type ReadOnlyClasses = { readonly [classSuffix: string]: boolean | string }
type SerializedTextNode = { classes?: ReadOnlyClasses } & SerializedLexicalTextNode
export class TextNode extends LexicalTextNode {
/** Don't use this directly, use `this.getClasses()` and `this.mutateClasses()` instead */
private __classes: ReadOnlyClasses = {}
static clone(node: TextNode) {
const clonedNode = new TextNode(node.__text, node.__key)
clonedNode.__classes = node.__classes
return clonedNode
}
static getType() {
return '$text'
}
static importJSON(serializedNode: SerializedTextNode): TextNode {
const textNode = new TextNode(serializedNode.text)
textNode.__classes = serializedNode.classes || {}
return textNode
}
createDOM(config: EditorConfig) {
const dom = super.createDOM(config)
// add classes to the text node
if (this.__classes) {
Object.entries(this.__classes).forEach(([classPrefix, classSufix]) => {
if (typeof classSufix === 'string') {
dom.classList.add(`${classPrefix}-${classSufix}`)
} else if (typeof classSufix === 'boolean' && classSufix) {
dom.classList.add(classPrefix)
}
})
}
return dom
}
exportJSON(): SerializedTextNode {
const classes = Object.fromEntries(
Object.entries(this.__classes).filter(([_, value]) => value !== undefined || value === false),
)
return {
...super.exportJSON(),
type: TextNode.getType(),
...(Object.keys(classes).keys.length > 0 && { classes: this.__classes }),
}
}
/**
* Returns an object of classes with the form `suffix: prefix`.
* For example, the key-value `bg-color: “blue”`, will cause the node to be rendered with the class `bg-color-blue`.
* This method is only for reading the classes object. If you need to mutate it use `muteClasses`.
*/
getClasses() {
const self = this.getLatest()
return self.__classes
}
/**
* Allows to mutate the object of classes whose form is `suffix: prefix`.
* For example, the key-value `bg-color: “blue”`, will cause the node to be rendered with the class `bg-color-blue`.
* @param fn A function that receives the current classes object and allows to mutate it.
*/
mutateClasses(fn: (currentClasses: MutableClasses) => void) {
const self = this.getWritable()
fn(self.__classes)
}
}

View File

@@ -1,27 +0,0 @@
import type { Klass, LexicalNode, LexicalNodeReplacement } from 'lexical'
import type { NodeWithHooks } from '../../features/typesServer.js'
import type { SanitizedClientEditorConfig, SanitizedServerEditorConfig } from '../config/types.js'
export function getEnabledNodes({
editorConfig,
}: {
editorConfig: SanitizedClientEditorConfig | SanitizedServerEditorConfig
}): Array<Klass<LexicalNode> | LexicalNodeReplacement> {
return getEnabledNodesFromServerNodes({
nodes: editorConfig.features.nodes,
})
}
export function getEnabledNodesFromServerNodes({
nodes,
}: {
nodes: Array<Klass<LexicalNode> | LexicalNodeReplacement> | Array<NodeWithHooks>
}): Array<Klass<LexicalNode> | LexicalNodeReplacement> {
return nodes.map((node) => {
if ('node' in node) {
return node.node
}
return node
})
}

View File

@@ -0,0 +1,169 @@
import type { BaseSelection, Klass, LexicalNode, LexicalNodeReplacement } from 'lexical'
import { $isTextNode, $isTokenOrSegmented } from 'lexical'
import type { NodeWithHooks } from '../../features/typesServer.js'
import type { SanitizedClientEditorConfig, SanitizedServerEditorConfig } from '../config/types.js'
import type { TextNode } from './TextNode.js'
export function getEnabledNodes({
editorConfig,
}: {
editorConfig: SanitizedClientEditorConfig | SanitizedServerEditorConfig
}): Array<Klass<LexicalNode> | LexicalNodeReplacement> {
return getEnabledNodesFromServerNodes({
nodes: editorConfig.features.nodes,
})
}
export function getEnabledNodesFromServerNodes({
nodes,
}: {
nodes: Array<Klass<LexicalNode> | LexicalNodeReplacement> | Array<NodeWithHooks>
}): Array<Klass<LexicalNode> | LexicalNodeReplacement> {
return nodes.map((node) => {
if ('node' in node) {
return node.node
}
return node
})
}
/**
* Will update partially selected TextNodes by splitting the TextNode and applying
* the callback to the appropriate one.
* @param selection - The selected node(s) to update.
* @param fn - The callback to apply to the selected TextNodes.
*/
export function $mutateSelectedTextNodes(
selection: BaseSelection,
fn: (textNode: TextNode) => void,
): void {
const selectedNodes = selection.getNodes()
const selectedNodesLength = selectedNodes.length
const anchorAndFocus = selection.getStartEndPoints()
if (anchorAndFocus === null) {
return
}
const [anchor, focus] = anchorAndFocus
const lastIndex = selectedNodesLength - 1
let firstNode = selectedNodes[0]
let lastNode = selectedNodes[lastIndex]
const firstNodeText = firstNode.getTextContent()
const firstNodeTextLength = firstNodeText.length
const focusOffset = focus.offset
let anchorOffset = anchor.offset
const isBefore = anchor.isBefore(focus)
let startOffset = isBefore ? anchorOffset : focusOffset
let endOffset = isBefore ? focusOffset : anchorOffset
const startType = isBefore ? anchor.type : focus.type
const endType = isBefore ? focus.type : anchor.type
const endKey = isBefore ? focus.key : anchor.key
// This is the case where the user only selected the very end of the
// first node so we don't want to include it in the formatting change.
if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) {
const nextSibling = firstNode.getNextSibling()
if ($isTextNode(nextSibling)) {
// we basically make the second node the firstNode, changing offsets accordingly
anchorOffset = 0
startOffset = 0
firstNode = nextSibling
}
}
// This is the case where we only selected a single node
if (selectedNodes.length === 1) {
if ($isTextNode(firstNode) && firstNode.canHaveFormat()) {
startOffset =
startType === 'element' ? 0 : anchorOffset > focusOffset ? focusOffset : anchorOffset
endOffset =
endType === 'element'
? firstNodeTextLength
: anchorOffset > focusOffset
? anchorOffset
: focusOffset
// No actual text is selected, so do nothing.
if (startOffset === endOffset) {
return
}
// The entire node is selected or a token/segment, so just format it
if (
$isTokenOrSegmented(firstNode) ||
(startOffset === 0 && endOffset === firstNodeTextLength)
) {
fn(firstNode as TextNode)
firstNode.select(startOffset, endOffset)
} else {
// The node is partially selected, so split it into two nodes
// and style the selected one.
const splitNodes = firstNode.splitText(startOffset, endOffset)
const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1]
fn(replacement as TextNode)
replacement.select(0, endOffset - startOffset)
}
} // multiple nodes selected.
} else {
if (
$isTextNode(firstNode) &&
startOffset < firstNode.getTextContentSize() &&
firstNode.canHaveFormat()
) {
if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
// the entire first node isn't selected and it isn't a token or segmented, so split it
firstNode = firstNode.splitText(startOffset)[1]
startOffset = 0
if (isBefore) {
anchor.set(firstNode.getKey(), startOffset, 'text')
} else {
focus.set(firstNode.getKey(), startOffset, 'text')
}
}
fn(firstNode as TextNode)
}
if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
const lastNodeText = lastNode.getTextContent()
const lastNodeTextLength = lastNodeText.length
// The last node might not actually be the end node
//
// If not, assume the last node is fully-selected unless the end offset is
// zero.
if (lastNode.__key !== endKey && endOffset !== 0) {
endOffset = lastNodeTextLength
}
// if the entire last node isn't selected and it isn't a token or segmented, split it
if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) {
;[lastNode] = lastNode.splitText(endOffset)
}
if (endOffset !== 0 || endType === 'element') {
fn(lastNode as TextNode)
}
}
// style all the text nodes in between
for (let i = 1; i < lastIndex; i++) {
const selectedNode = selectedNodes[i]
const selectedNodeKey = selectedNode.getKey()
if (
$isTextNode(selectedNode) &&
selectedNode.canHaveFormat() &&
selectedNodeKey !== firstNode.getKey() &&
selectedNodeKey !== lastNode.getKey() &&
!selectedNode.isToken()
) {
fn(selectedNode as TextNode)
}
}
}
}

View File

@@ -0,0 +1,18 @@
export const BgColorIcon: React.FC = () => (
<svg
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m19 11-8-8-8.6 8.6a2 2 0 0 0 0 2.8l5.2 5.2c.8.8 2 .8 2.8 0L19 11Z" />
<path d="m5 2 5 5" />
<path d="M2 13h15" />
<path d="M22 20a2 2 0 1 1-4 0c0-1.6 1.7-2.4 2-4 .3 1.6 2 2.4 2 4Z" />
</svg>
)

View File

@@ -0,0 +1,17 @@
export const TextColorIcon: React.FC = () => (
<svg
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4 20h16" />
<path d="m6 16 6-12 6 12" />
<path d="M8 12h8" />
</svg>
)

View File

@@ -6,7 +6,7 @@ import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType, tabHasName } fro
import type { LexicalRichTextAdapter } from '../../types.js'
import { getEnabledNodes } from '../../lexical/nodes/index.js'
import { getEnabledNodes } from '../../lexical/nodes/utils.js'
type NestedRichTextFieldsArgs = {
data: Record<string, unknown>

View File

@@ -1,5 +1,14 @@
import type { CollectionConfig } from 'payload'
import {
FixedToolbarFeature,
InlineToolbarFeature,
lexicalEditor,
TextClassesFeature,
TextColorFeature,
TreeViewFeature,
} from '@payloadcms/richtext-lexical'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
@@ -12,6 +21,42 @@ export const PostsCollection: CollectionConfig = {
name: 'title',
type: 'text',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures.filter((feature) => feature === InlineToolbarFeature()),
FixedToolbarFeature(),
TextColorFeature({
colors: [
{ label: 'Red', classSuffix: 'red' },
{ label: 'Green', classSuffix: 'green' },
{ label: 'Blue', classSuffix: 'blue' },
],
bgColors: [
{ label: 'Red', classSuffix: 'red' },
{ label: 'Green', classSuffix: 'green' },
{ label: 'Blue', classSuffix: 'blue' },
],
}),
// TextColorFeature({
// colors: [
// { label: 'Red', value: '#ff0000' },
// { label: 'Green', value: 'green' },
// { label: 'Blue', value: 'blue' },
// ],
// // normalizeColor: (color) => {
// // if (color !== '#ff0000' && color !== 'green' && color !== 'blue') {
// // return null
// // }
// // return color
// // },
// }),
TreeViewFeature(),
],
}),
},
],
versions: {
drafts: true,

View File

@@ -70,6 +70,21 @@ export interface UserAuthOperations {
export interface Post {
id: string;
title?: string | null;
content?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -202,6 +217,7 @@ export interface PayloadMigration {
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;

View File

@@ -125,9 +125,9 @@ export interface Config {
user: User & {
collection: 'users';
};
jobs?: {
jobs: {
tasks: unknown;
workflows?: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {