mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-22 20:02:30 +00:00
Merge pull request #5415 from BookStackApp/more_lexical_fixes
Further Lexical Fixes
This commit is contained in:
commit
213a86e3c0
35 changed files with 573 additions and 241 deletions
lang/en
resources
icons/editor
js/wysiwyg
lexical
core
LexicalCommands.tsLexicalConstants.tsLexicalNode.tsLexicalReconciler.ts
__tests__/utils
index.tsnodes
shared
html
rich-text
table
services
testing.mdtodo.mdui
defaults
framework
sass
|
@ -13,6 +13,7 @@ return [
|
|||
'cancel' => 'Cancel',
|
||||
'save' => 'Save',
|
||||
'close' => 'Close',
|
||||
'apply' => 'Apply',
|
||||
'undo' => 'Undo',
|
||||
'redo' => 'Redo',
|
||||
'left' => 'Left',
|
||||
|
@ -147,6 +148,7 @@ return [
|
|||
'url' => 'URL',
|
||||
'text_to_display' => 'Text to display',
|
||||
'title' => 'Title',
|
||||
'browse_links' => 'Browse links',
|
||||
'open_link' => 'Open link',
|
||||
'open_link_in' => 'Open link in...',
|
||||
'open_link_current' => 'Current window',
|
||||
|
|
10
resources/icons/editor/color-display.svg
Normal file
10
resources/icons/editor/color-display.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<svg version="1.1" viewBox="0 -960 960 960" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<pattern id="pattern2" x="0.40000001" patternTransform="scale(200)" preserveAspectRatio="xMidYMid" xlink:href="#Checkerboard"/>
|
||||
<pattern id="Checkerboard" width="2" height="2" fill="#b6b6b6" patternTransform="translate(0) scale(10)" patternUnits="userSpaceOnUse" preserveAspectRatio="xMidYMid">
|
||||
<rect width="1" height="1"/>
|
||||
<rect x="1" y="1" width="1" height="1"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect class="editor-icon-color-display" x="103.53" y="-856.47" width="752.94" height="752.94" rx="47.059" ry="47.059" fill="url(#pattern2)" stroke="#666" stroke-linecap="square" stroke-linejoin="round" stroke-width="47.059"/>
|
||||
</svg>
|
After (image error) Size: 762 B |
1
resources/icons/editor/color-select.svg
Normal file
1
resources/icons/editor/color-select.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 32.5-156t88-127Q256-817 330-848.5T488-880q80 0 151 27.5t124.5 76q53.5 48.5 85 115T880-518q0 115-70 176.5T640-280h-74q-9 0-12.5 5t-3.5 11q0 12 15 34.5t15 51.5q0 50-27.5 74T480-80Zm0-400Zm-220 40q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm120-160q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm200 0q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm120 160q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17ZM480-160q9 0 14.5-5t5.5-13q0-14-15-33t-15-57q0-42 29-67t71-25h70q66 0 113-38.5T800-518q0-121-92.5-201.5T488-800q-136 0-232 93t-96 227q0 133 93.5 226.5T480-160Z"/></svg>
|
After (image error) Size: 808 B |
|
@ -8,7 +8,6 @@
|
|||
|
||||
import type {
|
||||
BaseSelection,
|
||||
ElementFormatType,
|
||||
LexicalCommand,
|
||||
LexicalNode,
|
||||
TextFormatType,
|
||||
|
@ -91,8 +90,6 @@ export const OUTDENT_CONTENT_COMMAND: LexicalCommand<void> = createCommand(
|
|||
);
|
||||
export const DROP_COMMAND: LexicalCommand<DragEvent> =
|
||||
createCommand('DROP_COMMAND');
|
||||
export const FORMAT_ELEMENT_COMMAND: LexicalCommand<ElementFormatType> =
|
||||
createCommand('FORMAT_ELEMENT_COMMAND');
|
||||
export const DRAGSTART_COMMAND: LexicalCommand<DragEvent> =
|
||||
createCommand('DRAGSTART_COMMAND');
|
||||
export const DRAGOVER_COMMAND: LexicalCommand<DragEvent> =
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import type {ElementFormatType} from './nodes/LexicalElementNode';
|
||||
import type {
|
||||
TextDetailType,
|
||||
TextFormatType,
|
||||
|
@ -111,27 +110,6 @@ export const DETAIL_TYPE_TO_DETAIL: Record<TextDetailType | string, number> = {
|
|||
unmergeable: IS_UNMERGEABLE,
|
||||
};
|
||||
|
||||
export const ELEMENT_TYPE_TO_FORMAT: Record<
|
||||
Exclude<ElementFormatType, ''>,
|
||||
number
|
||||
> = {
|
||||
center: IS_ALIGN_CENTER,
|
||||
end: IS_ALIGN_END,
|
||||
justify: IS_ALIGN_JUSTIFY,
|
||||
left: IS_ALIGN_LEFT,
|
||||
right: IS_ALIGN_RIGHT,
|
||||
start: IS_ALIGN_START,
|
||||
};
|
||||
|
||||
export const ELEMENT_FORMAT_TO_TYPE: Record<number, ElementFormatType> = {
|
||||
[IS_ALIGN_CENTER]: 'center',
|
||||
[IS_ALIGN_END]: 'end',
|
||||
[IS_ALIGN_JUSTIFY]: 'justify',
|
||||
[IS_ALIGN_LEFT]: 'left',
|
||||
[IS_ALIGN_RIGHT]: 'right',
|
||||
[IS_ALIGN_START]: 'start',
|
||||
};
|
||||
|
||||
export const TEXT_MODE_TO_TYPE: Record<TextModeType, 0 | 1 | 2> = {
|
||||
normal: IS_NORMAL,
|
||||
segmented: IS_SEGMENTED,
|
||||
|
|
|
@ -146,6 +146,12 @@ type NodeName = string;
|
|||
* Output for a DOM conversion.
|
||||
* Node can be set to 'ignore' to ignore the conversion and handling of the DOMNode
|
||||
* including all its children.
|
||||
*
|
||||
* You can specify a function to run for each converted child (forChild) or on all
|
||||
* the child nodes after the conversion is complete (after).
|
||||
* The key difference here is that forChild runs for every deeply nested child node
|
||||
* of the current node, whereas after will run only once after the
|
||||
* transformation of the node and all its children is complete.
|
||||
*/
|
||||
export type DOMConversionOutput = {
|
||||
after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
|
||||
|
@ -1165,6 +1171,16 @@ export class LexicalNode {
|
|||
markDirty(): void {
|
||||
this.getWritable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the DOM of this node into that of the parent.
|
||||
* Allows this node to implement custom DOM attachment logic.
|
||||
* Boolean result indicates if the insertion was handled by the function.
|
||||
* A true return value prevents default insertion logic from taking place.
|
||||
*/
|
||||
insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function errorOnTypeKlassMismatch(
|
||||
|
|
|
@ -171,16 +171,21 @@ function $createNode(
|
|||
}
|
||||
|
||||
if (parentDOM !== null) {
|
||||
if (insertDOM != null) {
|
||||
parentDOM.insertBefore(dom, insertDOM);
|
||||
} else {
|
||||
// @ts-expect-error: internal field
|
||||
const possibleLineBreak = parentDOM.__lexicalLineBreak;
|
||||
|
||||
if (possibleLineBreak != null) {
|
||||
parentDOM.insertBefore(dom, possibleLineBreak);
|
||||
const inserted = node?.insertDOMIntoParent(dom, parentDOM);
|
||||
|
||||
if (!inserted) {
|
||||
if (insertDOM != null) {
|
||||
parentDOM.insertBefore(dom, insertDOM);
|
||||
} else {
|
||||
parentDOM.appendChild(dom);
|
||||
// @ts-expect-error: internal field
|
||||
const possibleLineBreak = parentDOM.__lexicalLineBreak;
|
||||
|
||||
if (possibleLineBreak != null) {
|
||||
parentDOM.insertBefore(dom, possibleLineBreak);
|
||||
} else {
|
||||
parentDOM.appendChild(dom);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,8 +37,6 @@ import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
|
|||
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
||||
import {EditorUiContext} from "../../../../ui/framework/core";
|
||||
import {EditorUIManager} from "../../../../ui/framework/manager";
|
||||
import {turtle} from "@codemirror/legacy-modes/mode/turtle";
|
||||
|
||||
|
||||
type TestEnv = {
|
||||
readonly container: HTMLDivElement;
|
||||
|
@ -47,6 +45,9 @@ type TestEnv = {
|
|||
readonly innerHTML: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated - Consider using `createTestContext` instead within the test case.
|
||||
*/
|
||||
export function initializeUnitTest(
|
||||
runTests: (testEnv: TestEnv) => void,
|
||||
editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
|
||||
|
@ -795,6 +796,30 @@ export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShap
|
|||
expect(shape.children).toMatchObject(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect a given prop within the JSON editor state structure to be the given value.
|
||||
* Uses dot notation for the provided `propPath`. Example:
|
||||
* 0.5.cat => First child, Sixth child, cat property
|
||||
*/
|
||||
export function expectEditorStateJSONPropToEqual(editor: LexicalEditor, propPath: string, expected: any) {
|
||||
let currentItem: any = editor.getEditorState().toJSON().root;
|
||||
let currentPath = [];
|
||||
const pathParts = propPath.split('.');
|
||||
|
||||
for (const part of pathParts) {
|
||||
currentPath.push(part);
|
||||
const childAccess = Number.isInteger(Number(part)) && Array.isArray(currentItem.children);
|
||||
const target = childAccess ? currentItem.children : currentItem;
|
||||
|
||||
if (typeof target[part] === 'undefined') {
|
||||
throw new Error(`Could not resolve editor state at path ${currentPath.join('.')}`)
|
||||
}
|
||||
currentItem = target[part];
|
||||
}
|
||||
|
||||
expect(currentItem).toBe(expected);
|
||||
}
|
||||
|
||||
function formatHtml(s: string): string {
|
||||
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
|
||||
}
|
||||
|
|
|
@ -49,15 +49,12 @@ export type {
|
|||
} from './LexicalNode';
|
||||
export type {
|
||||
BaseSelection,
|
||||
ElementPointType as ElementPoint,
|
||||
NodeSelection,
|
||||
Point,
|
||||
PointType,
|
||||
RangeSelection,
|
||||
TextPointType as TextPoint,
|
||||
} from './LexicalSelection';
|
||||
export type {
|
||||
ElementFormatType,
|
||||
SerializedElementNode,
|
||||
} from './nodes/LexicalElementNode';
|
||||
export type {SerializedRootNode} from './nodes/LexicalRootNode';
|
||||
|
@ -87,7 +84,6 @@ export {
|
|||
DRAGSTART_COMMAND,
|
||||
DROP_COMMAND,
|
||||
FOCUS_COMMAND,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
INDENT_CONTENT_COMMAND,
|
||||
INSERT_LINE_BREAK_COMMAND,
|
||||
|
|
|
@ -46,15 +46,6 @@ export type SerializedElementNode<
|
|||
SerializedLexicalNode
|
||||
>;
|
||||
|
||||
export type ElementFormatType =
|
||||
| 'left'
|
||||
| 'start'
|
||||
| 'center'
|
||||
| 'right'
|
||||
| 'end'
|
||||
| 'justify'
|
||||
| '';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export interface ElementNode {
|
||||
getTopLevelElement(): ElementNode | null;
|
||||
|
|
|
@ -1314,6 +1314,11 @@ const nodeNameToTextFormat: Record<string, TextFormatType> = {
|
|||
|
||||
function convertTextFormatElement(domNode: HTMLElement): DOMConversionOutput {
|
||||
const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()];
|
||||
|
||||
if (format === 'code' && domNode.closest('pre')) {
|
||||
return {node: null};
|
||||
}
|
||||
|
||||
if (format === undefined) {
|
||||
return {node: null};
|
||||
}
|
||||
|
|
|
@ -18,9 +18,9 @@ export default function invariant(
|
|||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Internal Lexical error: invariant() is meant to be replaced at compile ' +
|
||||
'time. There is no runtime version. Error: ' +
|
||||
message,
|
||||
);
|
||||
for (const arg of args) {
|
||||
message = (message || '').replace('%s', arg);
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import type {
|
|||
DOMChildConversion,
|
||||
DOMConversion,
|
||||
DOMConversionFn,
|
||||
ElementFormatType,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
} from 'lexical';
|
||||
|
@ -58,6 +57,7 @@ export function $generateNodesFromDOM(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
$unwrapArtificalNodes(allArtificialNodes);
|
||||
|
||||
return lexicalNodes;
|
||||
|
@ -324,8 +324,6 @@ function wrapContinuousInlines(
|
|||
nodes: Array<LexicalNode>,
|
||||
createWrapperFn: () => ElementNode,
|
||||
): Array<LexicalNode> {
|
||||
const textAlign = (domNode as HTMLElement).style
|
||||
.textAlign as ElementFormatType;
|
||||
const out: Array<LexicalNode> = [];
|
||||
let continuousInlines: Array<LexicalNode> = [];
|
||||
// wrap contiguous inline child nodes in para
|
||||
|
|
|
@ -145,7 +145,14 @@ export class CodeBlockNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||
node.setId(element.id);
|
||||
}
|
||||
|
||||
return { node };
|
||||
return {
|
||||
node,
|
||||
after(childNodes): LexicalNode[] {
|
||||
// Remove any child nodes that may get parsed since we're manually
|
||||
// controlling the code contents.
|
||||
return [];
|
||||
},
|
||||
};
|
||||
},
|
||||
priority: 3,
|
||||
};
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "lexical/nodes/common";
|
||||
import {$selectSingleNode} from "../../utils/selection";
|
||||
import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
|
||||
import * as url from "node:url";
|
||||
|
||||
export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
|
||||
export type MediaNodeSource = {
|
||||
|
@ -343,11 +344,55 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null {
|
|||
return domElementToNode(tag as MediaNodeTag, el);
|
||||
}
|
||||
|
||||
interface UrlPattern {
|
||||
readonly regex: RegExp;
|
||||
readonly w: number;
|
||||
readonly h: number;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* These patterns originate from the tinymce/tinymce project.
|
||||
* https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts
|
||||
* License: MIT Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
|
||||
* License Link: https://github.com/tinymce/tinymce/blob/584a150679669859a528828e5d2910a083b1d911/LICENSE.TXT
|
||||
*/
|
||||
const urlPatterns: UrlPattern[] = [
|
||||
{
|
||||
regex: /.*?youtu\.be\/([\w\-_\?&=.]+)/i,
|
||||
w: 560, h: 314,
|
||||
url: 'https://www.youtube.com/embed/$1',
|
||||
},
|
||||
{
|
||||
regex: /.*youtube\.com(.+)v=([^&]+)(&([a-z0-9&=\-_]+))?.*/i,
|
||||
w: 560, h: 314,
|
||||
url: 'https://www.youtube.com/embed/$2?$4',
|
||||
},
|
||||
{
|
||||
regex: /.*youtube.com\/embed\/([a-z0-9\?&=\-_]+).*/i,
|
||||
w: 560, h: 314,
|
||||
url: 'https://www.youtube.com/embed/$1',
|
||||
},
|
||||
];
|
||||
|
||||
const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov'];
|
||||
const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm'];
|
||||
const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx', ''];
|
||||
|
||||
export function $createMediaNodeFromSrc(src: string): MediaNode {
|
||||
|
||||
for (const pattern of urlPatterns) {
|
||||
const match = src.match(pattern.regex);
|
||||
if (match) {
|
||||
const newSrc = src.replace(pattern.regex, pattern.url);
|
||||
const node = new MediaNode('iframe');
|
||||
node.setSrc(newSrc);
|
||||
node.setHeight(pattern.h);
|
||||
node.setWidth(pattern.w);
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
let nodeTag: MediaNodeTag = 'iframe';
|
||||
const srcEnd = src.split('?')[0].split('/').pop() || '';
|
||||
const srcEndSplit = srcEnd.split('.');
|
||||
|
@ -360,7 +405,9 @@ export function $createMediaNodeFromSrc(src: string): MediaNode {
|
|||
nodeTag = 'embed';
|
||||
}
|
||||
|
||||
return new MediaNode(nodeTag);
|
||||
const node = new MediaNode(nodeTag);
|
||||
node.setSrc(src);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode {
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import type {
|
||||
CommandPayloadType,
|
||||
ElementFormatType,
|
||||
LexicalCommand,
|
||||
LexicalEditor,
|
||||
PasteCommandType,
|
||||
|
@ -44,7 +43,6 @@ import {
|
|||
DRAGSTART_COMMAND,
|
||||
DROP_COMMAND,
|
||||
ElementNode,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
INSERT_LINE_BREAK_COMMAND,
|
||||
INSERT_PARAGRAPH_COMMAND,
|
||||
|
@ -285,25 +283,6 @@ export function registerRichText(editor: LexicalEditor): () => void {
|
|||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand<ElementFormatType>(
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
(format) => {
|
||||
const selection = $getSelection();
|
||||
if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
const nodes = selection.getNodes();
|
||||
for (const node of nodes) {
|
||||
const element = $findMatchingParent(
|
||||
node,
|
||||
(parentNode): parentNode is ElementNode =>
|
||||
$isElementNode(parentNode) && !parentNode.isInline(),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand<boolean>(
|
||||
INSERT_LINE_BREAK_COMMAND,
|
||||
(selectStart) => {
|
||||
|
|
92
resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts
Normal file
92
resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
import {
|
||||
$createTextNode,
|
||||
DOMConversionMap,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
SerializedElementNode
|
||||
} from "lexical";
|
||||
import {TableNode} from "@lexical/table/LexicalTableNode";
|
||||
|
||||
|
||||
export class CaptionNode extends ElementNode {
|
||||
static getType(): string {
|
||||
return 'caption';
|
||||
}
|
||||
|
||||
static clone(node: CaptionNode): CaptionNode {
|
||||
return new CaptionNode(node.__key);
|
||||
}
|
||||
|
||||
createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement {
|
||||
return document.createElement('caption');
|
||||
}
|
||||
|
||||
updateDOM(_prevNode: unknown, _dom: HTMLElement, _config: EditorConfig): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isParentRequired(): true {
|
||||
return true;
|
||||
}
|
||||
|
||||
canBeEmpty(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedElementNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: 'caption',
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean {
|
||||
parentDOM.insertBefore(nodeDOM, parentDOM.firstChild);
|
||||
return true;
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedElementNode): CaptionNode {
|
||||
return $createCaptionNode();
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
caption: (node: Node) => ({
|
||||
conversion(domNode: Node) {
|
||||
return {
|
||||
node: $createCaptionNode(),
|
||||
}
|
||||
},
|
||||
priority: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function $createCaptionNode(): CaptionNode {
|
||||
return new CaptionNode();
|
||||
}
|
||||
|
||||
export function $isCaptionNode(node: LexicalNode | null | undefined): node is CaptionNode {
|
||||
return node instanceof CaptionNode;
|
||||
}
|
||||
|
||||
export function $tableHasCaption(table: TableNode): boolean {
|
||||
for (const child of table.getChildren()) {
|
||||
if ($isCaptionNode(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function $addCaptionToTable(table: TableNode, text: string = ''): void {
|
||||
const caption = $createCaptionNode();
|
||||
const textNode = $createTextNode(text || ' ');
|
||||
caption.append(textNode);
|
||||
table.append(caption);
|
||||
}
|
|
@ -139,6 +139,8 @@ export class TableNode extends CommonBlockNode {
|
|||
for (const child of Array.from(tableElement.children)) {
|
||||
if (child.nodeName === 'TR') {
|
||||
tBody.append(child);
|
||||
} else if (child.nodeName === 'CAPTION') {
|
||||
newElement.insertBefore(child, newElement.firstChild);
|
||||
} else {
|
||||
newElement.append(child);
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import {EditorUiContext} from "./ui/framework/core";
|
|||
import {MediaNode} from "@lexical/rich-text/LexicalMediaNode";
|
||||
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
|
||||
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
|
||||
import {CaptionNode} from "@lexical/table/LexicalCaptionNode";
|
||||
|
||||
/**
|
||||
* Load the nodes for lexical.
|
||||
|
@ -32,6 +33,7 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
|
|||
TableNode,
|
||||
TableRowNode,
|
||||
TableCellNode,
|
||||
CaptionNode,
|
||||
ImageNode, // TODO - Alignment
|
||||
HorizontalRuleNode,
|
||||
DetailsNode,
|
||||
|
|
|
@ -1,91 +1,76 @@
|
|||
import {initializeUnitTest} from "lexical/__tests__/utils";
|
||||
import {SerializedLinkNode} from "@lexical/link";
|
||||
import {
|
||||
createTestContext,
|
||||
dispatchKeydownEventForNode, expectEditorStateJSONPropToEqual,
|
||||
expectNodeShapeToMatch
|
||||
} from "lexical/__tests__/utils";
|
||||
import {
|
||||
$getRoot,
|
||||
ParagraphNode,
|
||||
SerializedParagraphNode,
|
||||
SerializedTextNode,
|
||||
TextNode
|
||||
} from "lexical";
|
||||
import {registerAutoLinks} from "../auto-links";
|
||||
|
||||
describe('Auto-link service tests', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
test('space after link in text', async () => {
|
||||
const {editor} = createTestContext();
|
||||
registerAutoLinks(editor);
|
||||
let pNode!: ParagraphNode;
|
||||
|
||||
test('space after link in text', async () => {
|
||||
const {editor} = testEnv;
|
||||
editor.updateAndCommit(() => {
|
||||
pNode = new ParagraphNode();
|
||||
const text = new TextNode('Some https://example.com?test=true text');
|
||||
pNode.append(text);
|
||||
$getRoot().append(pNode);
|
||||
|
||||
registerAutoLinks(editor);
|
||||
let pNode!: ParagraphNode;
|
||||
|
||||
editor.update(() => {
|
||||
pNode = new ParagraphNode();
|
||||
const text = new TextNode('Some https://example.com?test=true text');
|
||||
pNode.append(text);
|
||||
$getRoot().append(pNode);
|
||||
|
||||
text.select(34, 34);
|
||||
});
|
||||
|
||||
editor.commitUpdates();
|
||||
|
||||
const pDomEl = editor.getElementByKey(pNode.getKey());
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: ' ',
|
||||
keyCode: 62,
|
||||
});
|
||||
pDomEl?.dispatchEvent(event);
|
||||
|
||||
editor.commitUpdates();
|
||||
|
||||
const paragraph = editor!.getEditorState().toJSON().root
|
||||
.children[0] as SerializedParagraphNode;
|
||||
expect(paragraph.children[1].type).toBe('link');
|
||||
|
||||
const link = paragraph.children[1] as SerializedLinkNode;
|
||||
expect(link.url).toBe('https://example.com?test=true');
|
||||
const linkText = link.children[0] as SerializedTextNode;
|
||||
expect(linkText.text).toBe('https://example.com?test=true');
|
||||
text.select(34, 34);
|
||||
});
|
||||
|
||||
test('enter after link in text', async () => {
|
||||
const {editor} = testEnv;
|
||||
dispatchKeydownEventForNode(pNode, editor, ' ');
|
||||
|
||||
registerAutoLinks(editor);
|
||||
let pNode!: ParagraphNode;
|
||||
expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true');
|
||||
expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true');
|
||||
});
|
||||
|
||||
editor.update(() => {
|
||||
pNode = new ParagraphNode();
|
||||
const text = new TextNode('Some https://example.com?test=true text');
|
||||
pNode.append(text);
|
||||
$getRoot().append(pNode);
|
||||
test('space after link at end of line', async () => {
|
||||
const {editor} = createTestContext();
|
||||
registerAutoLinks(editor);
|
||||
let pNode!: ParagraphNode;
|
||||
|
||||
text.select(34, 34);
|
||||
});
|
||||
editor.updateAndCommit(() => {
|
||||
pNode = new ParagraphNode();
|
||||
const text = new TextNode('Some https://example.com?test=true');
|
||||
pNode.append(text);
|
||||
$getRoot().append(pNode);
|
||||
|
||||
editor.commitUpdates();
|
||||
|
||||
const pDomEl = editor.getElementByKey(pNode.getKey());
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 'Enter',
|
||||
keyCode: 66,
|
||||
});
|
||||
pDomEl?.dispatchEvent(event);
|
||||
|
||||
editor.commitUpdates();
|
||||
|
||||
const paragraph = editor!.getEditorState().toJSON().root
|
||||
.children[0] as SerializedParagraphNode;
|
||||
expect(paragraph.children[1].type).toBe('link');
|
||||
|
||||
const link = paragraph.children[1] as SerializedLinkNode;
|
||||
expect(link.url).toBe('https://example.com?test=true');
|
||||
const linkText = link.children[0] as SerializedTextNode;
|
||||
expect(linkText.text).toBe('https://example.com?test=true');
|
||||
text.selectEnd();
|
||||
});
|
||||
|
||||
dispatchKeydownEventForNode(pNode, editor, ' ');
|
||||
|
||||
expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [
|
||||
{text: 'Some '},
|
||||
{type: 'link', children: [{text: 'https://example.com?test=true'}]}
|
||||
]}]);
|
||||
expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true');
|
||||
});
|
||||
|
||||
test('enter after link in text', async () => {
|
||||
const {editor} = createTestContext();
|
||||
registerAutoLinks(editor);
|
||||
let pNode!: ParagraphNode;
|
||||
|
||||
editor.updateAndCommit(() => {
|
||||
pNode = new ParagraphNode();
|
||||
const text = new TextNode('Some https://example.com?test=true text');
|
||||
pNode.append(text);
|
||||
$getRoot().append(pNode);
|
||||
|
||||
text.select(34, 34);
|
||||
});
|
||||
|
||||
dispatchKeydownEventForNode(pNode, editor, 'Enter');
|
||||
|
||||
expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true');
|
||||
expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true');
|
||||
});
|
||||
});
|
|
@ -43,7 +43,7 @@ function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, edit
|
|||
linkNode.append(new TextNode(textSegment));
|
||||
|
||||
const splits = node.splitText(startIndex, cursorPoint);
|
||||
const targetIndex = splits.length === 3 ? 1 : 0;
|
||||
const targetIndex = startIndex > 0 ? 1 : 0;
|
||||
const targetText = splits[targetIndex];
|
||||
if (targetText) {
|
||||
targetText.replace(linkNode);
|
||||
|
|
55
resources/js/wysiwyg/testing.md
Normal file
55
resources/js/wysiwyg/testing.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
# Testing Guidance
|
||||
|
||||
This is testing guidance specific for this Lexical-based WYSIWYG editor.
|
||||
There is a lot of pre-existing test code carried over form the fork of lexical, but since there we've added a range of helpers and altered how testing can be done to make things a bit simpler and aligned with how we run tests.
|
||||
|
||||
This document is an attempt to document the new best options for added tests with an aim for standardisation on these approaches going forward.
|
||||
|
||||
## Utils Location
|
||||
|
||||
Most core test utils can be found in the file at path: resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
|
||||
|
||||
## Test Example
|
||||
|
||||
This is an example of a typical test using the common modern utilities to help perform actions or assertions. Comments are for this example only, and are not expected in actual test files.
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTestContext,
|
||||
dispatchKeydownEventForNode,
|
||||
expectEditorStateJSONPropToEqual,
|
||||
expectNodeShapeToMatch
|
||||
} from "lexical/__tests__/utils";
|
||||
import {
|
||||
$getRoot,
|
||||
ParagraphNode,
|
||||
TextNode
|
||||
} from "lexical";
|
||||
|
||||
describe('A specific service or file or function', () => {
|
||||
test('it does thing', async () => {
|
||||
// Create the editor context and get an editor reference
|
||||
const {editor} = createTestContext();
|
||||
|
||||
// Run an action within the editor.
|
||||
let pNode: ParagraphNode;
|
||||
editor.updateAndCommit(() => {
|
||||
pNode = new ParagraphNode();
|
||||
const text = new TextNode('Hello!');
|
||||
pNode.append(text);
|
||||
$getRoot().append(pNode);
|
||||
});
|
||||
|
||||
// Dispatch key events via the DOM
|
||||
dispatchKeydownEventForNode(pNode!, editor, ' ');
|
||||
|
||||
// Check the shape (and text) of the resulting state
|
||||
expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [
|
||||
{text: 'Hello!'},
|
||||
]}]);
|
||||
|
||||
// Check specific props in the resulting JSON state
|
||||
expectEditorStateJSONPropToEqual(editor, '0.0.text', 'Hello!');
|
||||
});
|
||||
});
|
||||
```
|
|
@ -1,27 +0,0 @@
|
|||
# Lexical based editor todo
|
||||
|
||||
## In progress
|
||||
|
||||
Reorg
|
||||
- Merge custom nodes into original nodes
|
||||
- Reduce down to use CommonBlockNode where possible
|
||||
- Remove existing formatType/ElementFormatType references (replaced with alignment).
|
||||
- Remove existing indent references (replaced with inset).
|
||||
|
||||
## Main Todo
|
||||
|
||||
//
|
||||
|
||||
## Secondary Todo
|
||||
|
||||
- Color picker support in table form color fields
|
||||
- Color picker for color controls
|
||||
- Table caption text support
|
||||
- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
|
||||
- Deep check of translation coverage
|
||||
- About button & view
|
||||
- Mobile display and handling
|
||||
|
||||
## Bugs
|
||||
|
||||
//
|
|
@ -57,7 +57,7 @@ export const redo: EditorButtonDefinition = {
|
|||
|
||||
|
||||
export const source: EditorButtonDefinition = {
|
||||
label: 'Source',
|
||||
label: 'Source code',
|
||||
icon: sourceIcon,
|
||||
async action(context: EditorUiContext) {
|
||||
const modal = context.manager.createModal('source');
|
||||
|
|
|
@ -12,6 +12,8 @@ import subscriptIcon from "@icons/editor/subscript.svg";
|
|||
import codeIcon from "@icons/editor/code.svg";
|
||||
import formatClearIcon from "@icons/editor/format-clear.svg";
|
||||
import {$selectionContainsTextFormat} from "../../../utils/selection";
|
||||
import {$patchStyleText} from "@lexical/selection";
|
||||
import {context} from "esbuild";
|
||||
|
||||
function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition {
|
||||
return {
|
||||
|
@ -32,6 +34,18 @@ export const underline: EditorButtonDefinition = buildFormatButton('Underline',
|
|||
export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon};
|
||||
export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon};
|
||||
|
||||
function colorAction(context: EditorUiContext, property: string, color: string): void {
|
||||
context.editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if (selection) {
|
||||
$patchStyleText(selection, {[property]: color || null});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color);
|
||||
export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color);
|
||||
|
||||
export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon);
|
||||
export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon);
|
||||
export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon);
|
||||
|
|
|
@ -32,7 +32,7 @@ import {
|
|||
} from "../../../utils/selection";
|
||||
import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams";
|
||||
import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
|
||||
import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects";
|
||||
import {$showDetailsForm, $showImageForm, $showLinkForm, $showMediaForm} from "../forms/objects";
|
||||
import {formatCodeBlock} from "../../../utils/formats";
|
||||
|
||||
export const link: EditorButtonDefinition = {
|
||||
|
@ -165,27 +165,14 @@ export const diagramManager: EditorButtonDefinition = {
|
|||
};
|
||||
|
||||
export const media: EditorButtonDefinition = {
|
||||
label: 'Insert/edit Media',
|
||||
label: 'Insert/edit media',
|
||||
icon: mediaIcon,
|
||||
action(context: EditorUiContext) {
|
||||
const mediaModal = context.manager.createModal('media');
|
||||
|
||||
context.editor.getEditorState().read(() => {
|
||||
const selection = $getSelection();
|
||||
const selectedNode = $getNodeFromSelection(selection, $isMediaNode) as MediaNode | null;
|
||||
|
||||
let formDefaults = {};
|
||||
if (selectedNode) {
|
||||
const nodeAttrs = selectedNode.getAttributes();
|
||||
formDefaults = {
|
||||
src: nodeAttrs.src || nodeAttrs.data || '',
|
||||
width: nodeAttrs.width,
|
||||
height: nodeAttrs.height,
|
||||
embed: '',
|
||||
}
|
||||
}
|
||||
|
||||
mediaModal.show(formDefaults);
|
||||
$showMediaForm(selectedNode, context);
|
||||
});
|
||||
},
|
||||
isActive(selection: BaseSelection | null): boolean {
|
||||
|
|
|
@ -186,6 +186,23 @@ export const link: EditorFormDefinition = {
|
|||
],
|
||||
};
|
||||
|
||||
export function $showMediaForm(media: MediaNode|null, context: EditorUiContext): void {
|
||||
const mediaModal = context.manager.createModal('media');
|
||||
|
||||
let formDefaults = {};
|
||||
if (media) {
|
||||
const nodeAttrs = media.getAttributes();
|
||||
formDefaults = {
|
||||
src: nodeAttrs.src || nodeAttrs.data || '',
|
||||
width: nodeAttrs.width,
|
||||
height: nodeAttrs.height,
|
||||
embed: '',
|
||||
}
|
||||
}
|
||||
|
||||
mediaModal.show(formDefaults);
|
||||
}
|
||||
|
||||
export const media: EditorFormDefinition = {
|
||||
submitText: 'Save',
|
||||
async action(formData, context: EditorUiContext) {
|
||||
|
@ -215,12 +232,19 @@ export const media: EditorFormDefinition = {
|
|||
const height = (formData.get('height') || '').toString().trim();
|
||||
const width = (formData.get('width') || '').toString().trim();
|
||||
|
||||
const updateNode = selectedNode || $createMediaNodeFromSrc(src);
|
||||
updateNode.setSrc(src);
|
||||
updateNode.setWidthAndHeight(width, height);
|
||||
if (!selectedNode) {
|
||||
$insertNodes([updateNode]);
|
||||
// Update existing
|
||||
if (selectedNode) {
|
||||
selectedNode.setSrc(src);
|
||||
selectedNode.setWidthAndHeight(width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert new
|
||||
const node = $createMediaNodeFromSrc(src);
|
||||
if (width || height) {
|
||||
node.setWidthAndHeight(width, height);
|
||||
}
|
||||
$insertNodes([node]);
|
||||
});
|
||||
|
||||
return true;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
EditorFormDefinition,
|
||||
EditorFormFieldDefinition,
|
||||
EditorFormFieldDefinition, EditorFormFields,
|
||||
EditorFormTabs,
|
||||
EditorSelectFormFieldDefinition
|
||||
} from "../../framework/forms";
|
||||
|
@ -17,6 +17,8 @@ import {
|
|||
import {formatSizeValue} from "../../../utils/dom";
|
||||
import {TableCellNode, TableNode, TableRowNode} from "@lexical/table";
|
||||
import {CommonBlockAlignment} from "lexical/nodes/common";
|
||||
import {colorFieldBuilder} from "../../framework/blocks/color-field";
|
||||
import {$addCaptionToTable, $isCaptionNode, $tableHasCaption} from "@lexical/table/LexicalCaptionNode";
|
||||
|
||||
const borderStyleInput: EditorSelectFormFieldDefinition = {
|
||||
label: 'Border style',
|
||||
|
@ -145,15 +147,15 @@ export const cellProperties: EditorFormDefinition = {
|
|||
} as EditorSelectFormFieldDefinition,
|
||||
];
|
||||
|
||||
const advancedFields: EditorFormFieldDefinition[] = [
|
||||
const advancedFields: EditorFormFields = [
|
||||
{
|
||||
label: 'Border width', // inline-style: border-width
|
||||
name: 'border_width',
|
||||
type: 'text',
|
||||
},
|
||||
borderStyleInput, // inline-style: border-style
|
||||
borderColorInput, // inline-style: border-color
|
||||
backgroundColorInput, // inline-style: background-color
|
||||
colorFieldBuilder(borderColorInput),
|
||||
colorFieldBuilder(backgroundColorInput),
|
||||
];
|
||||
|
||||
return new EditorFormTabs([
|
||||
|
@ -210,14 +212,15 @@ export const rowProperties: EditorFormDefinition = {
|
|||
type: 'text',
|
||||
},
|
||||
borderStyleInput, // style on tr: height
|
||||
borderColorInput, // style on tr: height
|
||||
backgroundColorInput, // style on tr: height
|
||||
colorFieldBuilder(borderColorInput),
|
||||
colorFieldBuilder(backgroundColorInput),
|
||||
],
|
||||
};
|
||||
|
||||
export function $showTablePropertiesForm(table: TableNode, context: EditorUiContext): EditorFormModal {
|
||||
const styles = table.getStyles();
|
||||
const modalForm = context.manager.createModal('table_properties');
|
||||
|
||||
modalForm.show({
|
||||
width: styles.get('width') || '',
|
||||
height: styles.get('height') || '',
|
||||
|
@ -227,7 +230,7 @@ export function $showTablePropertiesForm(table: TableNode, context: EditorUiCont
|
|||
border_style: styles.get('border-style') || '',
|
||||
border_color: styles.get('border-color') || '',
|
||||
background_color: styles.get('background-color') || '',
|
||||
// caption: '', TODO
|
||||
caption: $tableHasCaption(table) ? 'true' : '',
|
||||
align: table.getAlignment(),
|
||||
});
|
||||
return modalForm;
|
||||
|
@ -264,7 +267,17 @@ export const tableProperties: EditorFormDefinition = {
|
|||
});
|
||||
}
|
||||
|
||||
// TODO - cell caption
|
||||
const showCaption = Boolean(formData.get('caption')?.toString() || '');
|
||||
const hasCaption = $tableHasCaption(table);
|
||||
if (showCaption && !hasCaption) {
|
||||
$addCaptionToTable(table, context.translate('Caption'));
|
||||
} else if (!showCaption && hasCaption) {
|
||||
for (const child of table.getChildren()) {
|
||||
if ($isCaptionNode(child)) {
|
||||
child.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
@ -298,17 +311,17 @@ export const tableProperties: EditorFormDefinition = {
|
|||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'caption', // Caption element
|
||||
label: 'Show caption', // Caption element
|
||||
name: 'caption',
|
||||
type: 'text', // TODO -
|
||||
type: 'checkbox',
|
||||
},
|
||||
alignmentInput, // alignment class
|
||||
];
|
||||
|
||||
const advancedFields: EditorFormFieldDefinition[] = [
|
||||
borderStyleInput, // Style - border-style
|
||||
borderColorInput, // Style - border-color
|
||||
backgroundColorInput, // Style - background-color
|
||||
const advancedFields: EditorFormFields = [
|
||||
borderStyleInput,
|
||||
colorFieldBuilder(borderColorInput),
|
||||
colorFieldBuilder(backgroundColorInput),
|
||||
];
|
||||
|
||||
return new EditorFormTabs([
|
||||
|
|
|
@ -44,11 +44,11 @@ import {
|
|||
} from "./buttons/block-formats";
|
||||
import {
|
||||
bold, clearFormating, code,
|
||||
highlightColor,
|
||||
highlightColor, highlightColorAction,
|
||||
italic,
|
||||
strikethrough, subscript,
|
||||
superscript,
|
||||
textColor,
|
||||
textColor, textColorAction,
|
||||
underline
|
||||
} from "./buttons/inline-formats";
|
||||
import {
|
||||
|
@ -114,10 +114,10 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
|
|||
new EditorButton(italic),
|
||||
new EditorButton(underline),
|
||||
new EditorDropdownButton({ button: new EditorColorButton(textColor, 'color') }, [
|
||||
new EditorColorPicker('color'),
|
||||
new EditorColorPicker(textColorAction),
|
||||
]),
|
||||
new EditorDropdownButton({button: new EditorColorButton(highlightColor, 'background-color')}, [
|
||||
new EditorColorPicker('background-color'),
|
||||
new EditorColorPicker(highlightColorAction),
|
||||
]),
|
||||
new EditorButton(strikethrough),
|
||||
new EditorButton(superscript),
|
||||
|
|
56
resources/js/wysiwyg/ui/framework/blocks/color-field.ts
Normal file
56
resources/js/wysiwyg/ui/framework/blocks/color-field.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import {EditorContainerUiElement, EditorUiBuilderDefinition, EditorUiContext} from "../core";
|
||||
import {EditorFormField, EditorFormFieldDefinition} from "../forms";
|
||||
import {EditorColorPicker} from "./color-picker";
|
||||
import {EditorDropdownButton} from "./dropdown-button";
|
||||
|
||||
import colorDisplayIcon from "@icons/editor/color-display.svg"
|
||||
|
||||
export class EditorColorField extends EditorContainerUiElement {
|
||||
protected input: EditorFormField;
|
||||
protected pickerButton: EditorDropdownButton;
|
||||
|
||||
constructor(input: EditorFormField) {
|
||||
super([]);
|
||||
|
||||
this.input = input;
|
||||
|
||||
this.pickerButton = new EditorDropdownButton({
|
||||
button: { icon: colorDisplayIcon, label: 'Select color'}
|
||||
}, [
|
||||
new EditorColorPicker(this.onColorSelect.bind(this))
|
||||
]);
|
||||
this.addChildren(this.pickerButton, this.input);
|
||||
}
|
||||
|
||||
protected buildDOM(): HTMLElement {
|
||||
const dom = this.input.getDOMElement();
|
||||
dom.append(this.pickerButton.getDOMElement());
|
||||
dom.classList.add('editor-color-field-container');
|
||||
|
||||
const field = dom.querySelector('input') as HTMLInputElement;
|
||||
field.addEventListener('change', () => {
|
||||
this.setIconColor(field.value);
|
||||
});
|
||||
|
||||
return dom;
|
||||
}
|
||||
|
||||
onColorSelect(color: string, context: EditorUiContext): void {
|
||||
this.input.setValue(color);
|
||||
}
|
||||
|
||||
setIconColor(color: string) {
|
||||
const icon = this.getDOMElement().querySelector('svg .editor-icon-color-display');
|
||||
if (icon) {
|
||||
icon.setAttribute('fill', color || 'url(#pattern2)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function colorFieldBuilder(field: EditorFormFieldDefinition): EditorUiBuilderDefinition {
|
||||
return {
|
||||
build() {
|
||||
return new EditorColorField(new EditorFormField(field));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
import {EditorUiElement} from "../core";
|
||||
import {$getSelection} from "lexical";
|
||||
import {$patchStyleText} from "@lexical/selection";
|
||||
import {EditorUiContext, EditorUiElement} from "../core";
|
||||
import {el} from "../../../utils/dom";
|
||||
|
||||
import removeIcon from "@icons/editor/color-clear.svg";
|
||||
import selectIcon from "@icons/editor/color-select.svg";
|
||||
import {uniqueIdSmall} from "../../../../services/util";
|
||||
|
||||
const colorChoices = [
|
||||
'#000000',
|
||||
|
@ -34,18 +34,24 @@ const colorChoices = [
|
|||
'#34495E',
|
||||
];
|
||||
|
||||
const storageKey = 'bs-lexical-custom-colors';
|
||||
|
||||
export type EditorColorPickerCallback = (color: string, context: EditorUiContext) => void;
|
||||
|
||||
export class EditorColorPicker extends EditorUiElement {
|
||||
|
||||
protected styleProperty: string;
|
||||
protected callback: EditorColorPickerCallback;
|
||||
|
||||
constructor(styleProperty: string) {
|
||||
constructor(callback: EditorColorPickerCallback) {
|
||||
super();
|
||||
this.styleProperty = styleProperty;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
buildDOM(): HTMLElement {
|
||||
const id = uniqueIdSmall();
|
||||
|
||||
const colorOptions = colorChoices.map(choice => {
|
||||
const allChoices = [...colorChoices, ...this.getCustomColorChoices()];
|
||||
const colorOptions = allChoices.map(choice => {
|
||||
return el('div', {
|
||||
class: 'editor-color-select-option',
|
||||
style: `background-color: ${choice}`,
|
||||
|
@ -57,11 +63,30 @@ export class EditorColorPicker extends EditorUiElement {
|
|||
const removeButton = el('div', {
|
||||
class: 'editor-color-select-option',
|
||||
'data-color': '',
|
||||
title: 'Clear color',
|
||||
title: this.getContext().translate('Remove color'),
|
||||
}, []);
|
||||
removeButton.innerHTML = removeIcon;
|
||||
colorOptions.push(removeButton);
|
||||
|
||||
const selectButton = el('label', {
|
||||
class: 'editor-color-select-option',
|
||||
for: `color-select-${id}`,
|
||||
'data-color': '',
|
||||
title: this.getContext().translate('Custom color'),
|
||||
}, []);
|
||||
selectButton.innerHTML = selectIcon;
|
||||
colorOptions.push(selectButton);
|
||||
|
||||
const input = el('input', {type: 'color', hidden: 'true', id: `color-select-${id}`}) as HTMLInputElement;
|
||||
colorOptions.push(input);
|
||||
input.addEventListener('change', e => {
|
||||
if (input.value) {
|
||||
this.storeCustomColorChoice(input.value);
|
||||
this.setColor(input.value);
|
||||
this.rebuildDOM();
|
||||
}
|
||||
});
|
||||
|
||||
const colorRows = [];
|
||||
for (let i = 0; i < colorOptions.length; i+=5) {
|
||||
const options = colorOptions.slice(i, i + 5);
|
||||
|
@ -79,16 +104,33 @@ export class EditorColorPicker extends EditorUiElement {
|
|||
return wrapper;
|
||||
}
|
||||
|
||||
storeCustomColorChoice(color: string) {
|
||||
if (colorChoices.includes(color)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customColors: string[] = this.getCustomColorChoices();
|
||||
if (customColors.includes(color)) {
|
||||
return;
|
||||
}
|
||||
|
||||
customColors.push(color);
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(customColors));
|
||||
}
|
||||
|
||||
getCustomColorChoices(): string[] {
|
||||
return JSON.parse(window.localStorage.getItem(storageKey) || '[]');
|
||||
}
|
||||
|
||||
onClick(event: MouseEvent) {
|
||||
const colorEl = (event.target as HTMLElement).closest('[data-color]') as HTMLElement;
|
||||
if (!colorEl) return;
|
||||
|
||||
const color = colorEl.dataset.color as string;
|
||||
this.getContext().editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if (selection) {
|
||||
$patchStyleText(selection, {[this.styleProperty]: color || null});
|
||||
}
|
||||
});
|
||||
this.setColor(color);
|
||||
}
|
||||
|
||||
setColor(color: string) {
|
||||
this.callback(color, this.getContext());
|
||||
}
|
||||
}
|
|
@ -44,7 +44,6 @@ export class LinkField extends EditorContainerUiElement {
|
|||
|
||||
updateFormFromHeader(header: HeadingNode) {
|
||||
this.getHeaderIdAndText(header).then(({id, text}) => {
|
||||
console.log('updating form', id, text);
|
||||
const modal = this.getContext().manager.getActiveModal('link');
|
||||
if (modal) {
|
||||
modal.getForm().setValues({
|
||||
|
@ -60,7 +59,6 @@ export class LinkField extends EditorContainerUiElement {
|
|||
return new Promise((res) => {
|
||||
this.getContext().editor.update(() => {
|
||||
let id = header.getId();
|
||||
console.log('header', id, header.__id);
|
||||
if (!id) {
|
||||
id = 'header-' + uniqueIdSmall();
|
||||
header.setId(id);
|
||||
|
|
|
@ -53,6 +53,13 @@ export abstract class EditorUiElement {
|
|||
return this.dom;
|
||||
}
|
||||
|
||||
rebuildDOM(): HTMLElement {
|
||||
const newDOM = this.buildDOM();
|
||||
this.dom?.replaceWith(newDOM);
|
||||
this.dom = newDOM;
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
trans(text: string) {
|
||||
return this.getContext().translate(text);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import {el} from "../../utils/dom";
|
|||
export interface EditorFormFieldDefinition {
|
||||
label: string;
|
||||
name: string;
|
||||
type: 'text' | 'select' | 'textarea';
|
||||
type: 'text' | 'select' | 'textarea' | 'checkbox';
|
||||
}
|
||||
|
||||
export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition {
|
||||
|
@ -19,15 +19,17 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti
|
|||
valuesByLabel: Record<string, string>
|
||||
}
|
||||
|
||||
export type EditorFormFields = (EditorFormFieldDefinition|EditorUiBuilderDefinition)[];
|
||||
|
||||
interface EditorFormTabDefinition {
|
||||
label: string;
|
||||
contents: EditorFormFieldDefinition[];
|
||||
contents: EditorFormFields;
|
||||
}
|
||||
|
||||
export interface EditorFormDefinition {
|
||||
submitText: string;
|
||||
action: (formData: FormData, context: EditorUiContext) => Promise<boolean>;
|
||||
fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[];
|
||||
fields: EditorFormFields;
|
||||
}
|
||||
|
||||
export class EditorFormField extends EditorUiElement {
|
||||
|
@ -40,7 +42,12 @@ export class EditorFormField extends EditorUiElement {
|
|||
|
||||
setValue(value: string) {
|
||||
const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement;
|
||||
input.value = value;
|
||||
if (this.definition.type === 'checkbox') {
|
||||
input.checked = Boolean(value);
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
input.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
|
@ -58,6 +65,8 @@ export class EditorFormField extends EditorUiElement {
|
|||
input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems);
|
||||
} else if (this.definition.type === 'textarea') {
|
||||
input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'});
|
||||
} else if (this.definition.type === 'checkbox') {
|
||||
input = el('input', {id, name: this.definition.name, type: 'checkbox', class: 'editor-form-field-input-checkbox', value: 'true'});
|
||||
} else {
|
||||
input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'});
|
||||
}
|
||||
|
@ -155,11 +164,17 @@ export class EditorForm extends EditorContainerUiElement {
|
|||
export class EditorFormTab extends EditorContainerUiElement {
|
||||
|
||||
protected definition: EditorFormTabDefinition;
|
||||
protected fields: EditorFormField[];
|
||||
protected fields: EditorUiElement[];
|
||||
protected id: string;
|
||||
|
||||
constructor(definition: EditorFormTabDefinition) {
|
||||
const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef));
|
||||
const fields = definition.contents.map(fieldDef => {
|
||||
if (isUiBuilderDefinition(fieldDef)) {
|
||||
return fieldDef.build();
|
||||
}
|
||||
return new EditorFormField(fieldDef)
|
||||
});
|
||||
|
||||
super(fields);
|
||||
|
||||
this.definition = definition;
|
||||
|
|
|
@ -649,6 +649,16 @@ textarea.editor-form-field-input {
|
|||
width: $inputWidth - 40px;
|
||||
}
|
||||
}
|
||||
.editor-color-field-container {
|
||||
position: relative;
|
||||
input {
|
||||
padding-left: 36px;
|
||||
}
|
||||
.editor-dropdown-menu-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Editor theme styles
|
||||
.editor-theme-bold {
|
||||
|
|
Loading…
Add table
Reference in a new issue