Merge pull request #5365 from BookStackApp/lexical_fixes

Range of fixes/updates for the new Lexical based editor
This commit is contained in:
Dan Brown 2024-12-20 14:51:57 +00:00 committed by GitHub
commit 1f88bc2a59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 2125 additions and 920 deletions

View File

@ -0,0 +1,14 @@
// This is a basic transformer stub to help jest handle SVG files.
// Essentially blanks them since we don't really need to involve them
// in our tests (yet).
module.exports = {
process() {
return {
code: 'module.exports = \'\';',
};
},
getCacheKey() {
// The output is always the same.
return 'svgTransform';
},
};

View File

@ -185,6 +185,7 @@ const config: Config = {
// A map from regular expressions to paths to transformers
transform: {
"^.+.tsx?$": ["ts-jest",{}],
"^.+.svg$": ["<rootDir>/dev/build/svg-blank-transform.js",{}],
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation

View File

@ -163,6 +163,8 @@ return [
'about' => 'About the editor',
'about_title' => 'About the WYSIWYG Editor',
'editor_license' => 'Editor License & Copyright',
'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.',
'editor_lexical_license_link' => 'Full license details can be found here.',
'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.',
'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.',
'save_continue' => 'Save Page & Continue',

View File

Before

Width:  |  Height:  |  Size: 653 B

After

Width:  |  Height:  |  Size: 653 B

View File

@ -0,0 +1 @@
<svg viewbox="0 0 24 24"><path d="M8.12 19.3c.39.39 1.02.39 1.41 0L12 16.83l2.47 2.47c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41l-3.17-3.17c-.39-.39-1.02-.39-1.41 0l-3.17 3.17c-.4.38-.4 1.02-.01 1.41zm7.76-14.6c-.39-.39-1.02-.39-1.41 0L12 7.17 9.53 4.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.03 0 1.42l3.17 3.17c.39.39 1.02.39 1.41 0l3.17-3.17c.4-.39.4-1.03.01-1.42z"/></svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@ -4,7 +4,7 @@
function register(editor) {
const aboutDialog = {
title: 'About the WYSIWYG Editor',
url: window.baseUrl('/help/wysiwyg'),
url: window.baseUrl('/help/tinymce'),
};
editor.ui.registry.addButton('about', {

View File

@ -15,6 +15,7 @@ import {el} from "./utils/dom";
import {registerShortcuts} from "./services/shortcuts";
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
import {registerKeyboardHandling} from "./services/keyboard-handling";
import {registerAutoLinks} from "./services/auto-links";
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
const config: CreateEditorArgs = {
@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
registerTaskListHandler(editor, editArea),
registerDropPasteHandling(context),
registerNodeResizer(context),
registerAutoLinks(editor),
);
listenToCommonEvents(editor);
@ -73,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
const debugView = document.getElementById('lexical-debug');
if (debugView) {
debugView.hidden = true;
}
let changeFromLoading = true;
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
// Watch for selection changes to update the UI on change
// Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
// for all selection changes, so this proved more reliable.
const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
if (selectionChange) {
editor.update(() => {
const selection = $getSelection();
context.manager.triggerStateUpdate({
editor, selection,
});
});
}
// Emit change event to component system (for draft detection) on actual user content change
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
if (changeFromLoading) {
changeFromLoading = false;
} else {
window.$events.emit('editor-html-change', '');
}
}
// Debug logic
// console.log('editorState', editorState.toJSON());
if (debugView) {
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
// Debug logic
// console.log('editorState', editorState.toJSON());
debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
}
});
});
}
// @ts-ignore
window.debugEditorState = () => {

View File

@ -1188,6 +1188,14 @@ export class LexicalEditor {
updateEditor(this, updateFn, options);
}
/**
* Helper to run the update and commitUpdates methods in a single call.
*/
updateAndCommit(updateFn: () => void, options?: EditorUpdateOptions): void {
this.update(updateFn, options);
this.commitUpdates();
}
/**
* Focuses the editor
* @param callbackFn - A function to run after the editor is focused.

View File

@ -142,10 +142,15 @@ export type DOMConversionMap<T extends HTMLElement = HTMLElement> = Record<
>;
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.
*/
export type DOMConversionOutput = {
after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
forChild?: DOMChildConversion;
node: null | LexicalNode | Array<LexicalNode>;
node: null | LexicalNode | Array<LexicalNode> | 'ignore';
};
export type DOMExportOutputMap = Map<

View File

@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
import {
$getSelection,
$isRangeSelection,
createEditor,
DecoratorNode,
@ -29,14 +30,14 @@ import {
TextNode,
} from 'lexical';
import {
CreateEditorArgs,
HTMLConfig,
LexicalNodeReplacement,
} from '../../LexicalEditor';
import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor';
import {resetRandomKey} from '../../LexicalUtils';
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
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 = {
@ -420,6 +421,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
TableRowNode,
AutoLinkNode,
LinkNode,
DetailsNode,
TestElementNode,
TestSegmentedNode,
TestExcludeFromCopyElementNode,
@ -451,6 +453,7 @@ export function createTestEditor(
...config,
nodes: DEFAULT_NODES.concat(customNodes),
});
return editor;
}
@ -465,6 +468,48 @@ export function createTestHeadlessEditor(
});
}
export function createTestContext(): EditorUiContext {
const container = document.createElement('div');
document.body.appendChild(container);
const scrollWrap = document.createElement('div');
const editorDOM = document.createElement('div');
editorDOM.setAttribute('contenteditable', 'true');
scrollWrap.append(editorDOM);
container.append(scrollWrap);
const editor = createTestEditor({
namespace: 'testing',
theme: {},
});
editor.setRootElement(editorDOM);
const context = {
containerDOM: container,
editor: editor,
editorDOM: editorDOM,
error(text: string | Error): void {
},
manager: new EditorUIManager(),
options: {},
scrollDOM: scrollWrap,
translate(text: string): string {
return "";
}
};
context.manager.setContext(context);
return context;
}
export function destroyFromContext(context: EditorUiContext) {
context.containerDOM.remove();
}
export function $assertRangeSelection(selection: unknown): RangeSelection {
if (!$isRangeSelection(selection)) {
throw new Error(`Expected RangeSelection, got ${selection}`);
@ -715,6 +760,61 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
expect(formatHtml(expected)).toBe(formatHtml(actual));
}
type nodeTextShape = {
text: string;
};
type nodeShape = {
type: string;
children?: (nodeShape|nodeTextShape)[];
}
export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
// @ts-ignore
const children: SerializedLexicalNode[] = (node.children || []);
const shape: nodeShape = {
type: node.type,
};
if (shape.type === 'text') {
// @ts-ignore
return {text: node.text}
}
if (children.length > 0) {
shape.children = children.map(c => getNodeShape(c));
}
return shape;
}
export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
const json = editor.getEditorState().toJSON();
const shape = getNodeShape(json.root) as nodeShape;
expect(shape.children).toMatchObject(expected);
}
function formatHtml(s: string): string {
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
}
export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
const nodeDomEl = editor.getElementByKey(node.getKey());
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key,
});
nodeDomEl?.dispatchEvent(event);
editor.commitUpdates();
}
export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
editor.getEditorState().read((): void => {
const node = $getSelection()?.getNodes()[0] || null;
if (node) {
dispatchKeydownEventForNode(node, editor, key);
}
});
}

View File

@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => {
it('should be headless environment', async () => {
expect(typeof window === 'undefined').toBe(true);
expect(typeof document === 'undefined').toBe(true);
expect(typeof navigator === 'undefined').toBe(true);
});
it('can update editor', async () => {

View File

@ -217,6 +217,11 @@ function $createNodesFromDOM(
if (transformOutput !== null) {
postTransform = transformOutput.after;
const transformNodes = transformOutput.node;
if (transformNodes === 'ignore') {
return lexicalNodes;
}
currentLexicalNode = Array.isArray(transformNodes)
? transformNodes[transformNodes.length - 1]
: transformNodes;

View File

@ -271,11 +271,18 @@ export class ListItemNode extends ElementNode {
insertNewAfter(
_: RangeSelection,
restoreSelection = true,
): ListItemNode | ParagraphNode {
): ListItemNode | ParagraphNode | null {
if (this.getTextContent().trim() === '' && this.isLastChild()) {
const list = this.getParentOrThrow<ListNode>();
if (!$isListItemNode(list.getParent())) {
const parentListItem = list.getParent();
if ($isListItemNode(parentListItem)) {
// Un-nest list item if empty nested item
parentListItem.insertAfter(this);
this.selectStart();
return null;
} else {
// Insert empty paragraph after list if adding after last empty child
const paragraph = $createParagraphNode();
list.insertAfter(paragraph, restoreSelection);
this.remove();

View File

@ -5,18 +5,20 @@ import {
LexicalEditor,
LexicalNode,
SerializedElementNode, Spread,
EditorConfig,
EditorConfig, DOMExportOutput,
} from 'lexical';
import {el} from "../../utils/dom";
import {extractDirectionFromElement} from "lexical/nodes/common";
export type SerializedDetailsNode = Spread<{
id: string;
summary: string;
}, SerializedElementNode>
export class DetailsNode extends ElementNode {
__id: string = '';
__summary: string = '';
__open: boolean = false;
static getType() {
return 'details';
@ -32,10 +34,32 @@ export class DetailsNode extends ElementNode {
return self.__id;
}
setSummary(summary: string) {
const self = this.getWritable();
self.__summary = summary;
}
getSummary(): string {
const self = this.getLatest();
return self.__summary;
}
setOpen(open: boolean) {
const self = this.getWritable();
self.__open = open;
}
getOpen(): boolean {
const self = this.getLatest();
return self.__open;
}
static clone(node: DetailsNode): DetailsNode {
const newNode = new DetailsNode(node.__key);
newNode.__id = node.__id;
newNode.__dir = node.__dir;
newNode.__summary = node.__summary;
newNode.__open = node.__open;
return newNode;
}
@ -49,12 +73,34 @@ export class DetailsNode extends ElementNode {
el.setAttribute('dir', this.__dir);
}
if (this.__open) {
el.setAttribute('open', 'true');
}
const summary = document.createElement('summary');
summary.textContent = this.__summary;
summary.setAttribute('contenteditable', 'false');
summary.addEventListener('click', event => {
event.preventDefault();
_editor.update(() => {
this.select();
})
});
el.append(summary);
return el;
}
updateDOM(prevNode: DetailsNode, dom: HTMLElement) {
if (prevNode.__open !== this.__open) {
dom.toggleAttribute('open', this.__open);
}
return prevNode.__id !== this.__id
|| prevNode.__dir !== this.__dir;
|| prevNode.__dir !== this.__dir
|| prevNode.__summary !== this.__summary;
}
static importDOM(): DOMConversionMap|null {
@ -71,20 +117,44 @@ export class DetailsNode extends ElementNode {
node.setDirection(extractDirectionFromElement(element));
}
const summaryElem = Array.from(element.children).find(e => e.nodeName === 'SUMMARY');
node.setSummary(summaryElem?.textContent || '');
return {node};
},
priority: 3,
};
},
summary(node: HTMLElement): DOMConversion|null {
return {
conversion: (element: HTMLElement): DOMConversionOutput|null => {
return {node: 'ignore'};
},
priority: 3,
};
},
};
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = this.createDOM(editor._config, editor);
const editable = element.querySelectorAll('[contenteditable]');
for (const elem of editable) {
elem.removeAttribute('contenteditable');
}
element.removeAttribute('open');
return {element};
}
exportJSON(): SerializedDetailsNode {
return {
...super.exportJSON(),
type: 'details',
version: 1,
id: this.__id,
summary: this.__summary,
};
}
@ -104,58 +174,3 @@ export function $createDetailsNode() {
export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode {
return node instanceof DetailsNode;
}
export class SummaryNode extends ElementNode {
static getType() {
return 'summary';
}
static clone(node: SummaryNode) {
return new SummaryNode(node.__key);
}
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
return el('summary');
}
updateDOM(prevNode: DetailsNode, dom: HTMLElement) {
return false;
}
static importDOM(): DOMConversionMap|null {
return {
summary(node: HTMLElement): DOMConversion|null {
return {
conversion: (element: HTMLElement): DOMConversionOutput|null => {
return {
node: new SummaryNode(),
};
},
priority: 3,
};
},
};
}
exportJSON(): SerializedElementNode {
return {
...super.exportJSON(),
type: 'summary',
version: 1,
};
}
static importJSON(serializedNode: SerializedElementNode): SummaryNode {
return $createSummaryNode();
}
}
export function $createSummaryNode(): SummaryNode {
return new SummaryNode();
}
export function $isSummaryNode(node: LexicalNode | null | undefined): node is SummaryNode {
return node instanceof SummaryNode;
}

View File

@ -0,0 +1,40 @@
import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils";
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical";
const editorConfig = Object.freeze({
namespace: '',
theme: {
},
});
describe('LexicalDetailsNode tests', () => {
initializeUnitTest((testEnv) => {
test('createDOM()', () => {
const {editor} = testEnv;
let html!: string;
editor.updateAndCommit(() => {
const details = $createDetailsNode();
html = details.createDOM(editorConfig, editor).outerHTML;
});
expect(html).toBe(`<details><summary contenteditable="false"></summary></details>`);
});
test('exportDOM()', () => {
const {editor} = testEnv;
let html!: string;
editor.updateAndCommit(() => {
const details = $createDetailsNode();
html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
});
expect(html).toBe(`<details><summary></summary></details>`);
});
});
})

View File

@ -8,7 +8,7 @@ import {
} from "lexical";
import {LinkNode} from "@lexical/link";
import {ImageNode} from "@lexical/rich-text/LexicalImageNode";
import {DetailsNode, SummaryNode} from "@lexical/rich-text/LexicalDetailsNode";
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {ListItemNode, ListNode} from "@lexical/list";
import {TableCellNode, TableNode, TableRowNode} from "@lexical/table";
import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode";
@ -34,7 +34,7 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
TableCellNode,
ImageNode, // TODO - Alignment
HorizontalRuleNode,
DetailsNode, SummaryNode,
DetailsNode,
CodeBlockNode,
DiagramNode,
MediaNode, // TODO - Alignment

View File

@ -0,0 +1,91 @@
import {initializeUnitTest} from "lexical/__tests__/utils";
import {SerializedLinkNode} from "@lexical/link";
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} = testEnv;
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');
});
test('enter after link in text', async () => {
const {editor} = testEnv;
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: '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');
});
});
});

View File

@ -0,0 +1,130 @@
import {
createTestContext, destroyFromContext,
dispatchKeydownEventForNode,
dispatchKeydownEventForSelectedNode,
} from "lexical/__tests__/utils";
import {
$createParagraphNode, $createTextNode,
$getRoot, $getSelection, LexicalEditor, LexicalNode,
ParagraphNode, TextNode,
} from "lexical";
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {registerKeyboardHandling} from "../keyboard-handling";
import {registerRichText} from "@lexical/rich-text";
import {EditorUiContext} from "../../ui/framework/core";
import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list";
describe('Keyboard-handling service tests', () => {
let context!: EditorUiContext;
let editor!: LexicalEditor;
beforeEach(() => {
context = createTestContext();
editor = context.editor;
registerRichText(editor);
registerKeyboardHandling(context);
});
afterEach(() => {
destroyFromContext(context);
});
test('Details: down key on last lines creates new sibling node', () => {
let lastRootChild!: LexicalNode|null;
let detailsPara!: ParagraphNode;
editor.updateAndCommit(() => {
const root = $getRoot()
const details = $createDetailsNode();
detailsPara = $createParagraphNode();
details.append(detailsPara);
$getRoot().append(details);
detailsPara.select();
lastRootChild = root.getLastChild();
});
expect(lastRootChild).toBeInstanceOf(DetailsNode);
dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
editor.getEditorState().read(() => {
lastRootChild = $getRoot().getLastChild();
});
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
});
test('Details: enter on last empty block creates new sibling node', () => {
registerRichText(editor);
let lastRootChild!: LexicalNode|null;
let detailsPara!: ParagraphNode;
editor.updateAndCommit(() => {
const root = $getRoot()
const details = $createDetailsNode();
const text = $createTextNode('Hello!');
detailsPara = $createParagraphNode();
detailsPara.append(text);
details.append(detailsPara);
$getRoot().append(details);
text.selectEnd();
lastRootChild = root.getLastChild();
});
expect(lastRootChild).toBeInstanceOf(DetailsNode);
dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
dispatchKeydownEventForSelectedNode(editor, 'Enter');
let detailsChildren!: LexicalNode[];
let lastDetailsText!: string;
editor.getEditorState().read(() => {
detailsChildren = (lastRootChild as DetailsNode).getChildren();
lastRootChild = $getRoot().getLastChild();
lastDetailsText = detailsChildren[0].getTextContent();
});
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
expect(detailsChildren).toHaveLength(1);
expect(lastDetailsText).toBe('Hello!');
});
test('Lists: tab on empty list item insets item', () => {
let list!: ListNode;
let listItemB!: ListItemNode;
editor.updateAndCommit(() => {
const root = $getRoot();
list = $createListNode('bullet');
const listItemA = $createListItemNode();
listItemA.append($createTextNode('Hello!'));
listItemB = $createListItemNode();
list.append(listItemA, listItemB);
root.append(list);
listItemB.selectStart();
});
dispatchKeydownEventForNode(listItemB, editor, 'Tab');
editor.getEditorState().read(() => {
const list = $getRoot().getChildren()[0] as ListNode;
const listChild = list.getChildren()[0] as ListItemNode;
const children = listChild.getChildren();
expect(children).toHaveLength(2);
expect(children[0]).toBeInstanceOf(TextNode);
expect(children[0].getTextContent()).toBe('Hello!');
expect(children[1]).toBeInstanceOf(ListNode);
const innerList = children[1] as ListNode;
const selectedNode = $getSelection()?.getNodes()[0];
expect(selectedNode).toBeInstanceOf(ListItemNode);
expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey());
});
});
});

View File

@ -0,0 +1,74 @@
import {
$getSelection, BaseSelection,
COMMAND_PRIORITY_NORMAL,
KEY_ENTER_COMMAND,
KEY_SPACE_COMMAND,
LexicalEditor,
TextNode
} from "lexical";
import {$getTextNodeFromSelection} from "../utils/selection";
import {$createLinkNode, LinkNode} from "@lexical/link";
function isLinkText(text: string): boolean {
const lower = text.toLowerCase();
if (!lower.startsWith('http')) {
return false;
}
const linkRegex = /(http|https):\/\/(\S+)\.\S+$/;
return linkRegex.test(text);
}
function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, editor: LexicalEditor) {
const selectionRange = selection.getStartEndPoints();
if (!selectionRange) {
return;
}
const cursorPoint = selectionRange[0].offset;
const nodeText = node.getTextContent();
const rTrimText = nodeText.slice(0, cursorPoint);
const priorSpaceIndex = rTrimText.lastIndexOf(' ');
const startIndex = priorSpaceIndex + 1;
const textSegment = nodeText.slice(startIndex, cursorPoint);
if (!isLinkText(textSegment)) {
return;
}
editor.update(() => {
const linkNode: LinkNode = $createLinkNode(textSegment);
linkNode.append(new TextNode(textSegment));
const splits = node.splitText(startIndex, cursorPoint);
const targetIndex = splits.length === 3 ? 1 : 0;
const targetText = splits[targetIndex];
if (targetText) {
targetText.replace(linkNode);
}
});
}
export function registerAutoLinks(editor: LexicalEditor): () => void {
const handler = (payload: KeyboardEvent): boolean => {
const selection = $getSelection();
const textNode = $getTextNodeFromSelection(selection);
if (textNode && selection) {
handlePotentialLinkEvent(textNode, selection, editor);
}
return false;
};
const unregisterSpace = editor.registerCommand(KEY_SPACE_COMMAND, handler, COMMAND_PRIORITY_NORMAL);
const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, handler, COMMAND_PRIORITY_NORMAL);
return (): void => {
unregisterSpace();
unregisterEnter();
};
}

View File

@ -1,4 +1,4 @@
import {LexicalEditor} from "lexical";
import {$getSelection, LexicalEditor} from "lexical";
import {
appendHtmlToEditor,
focusEditor,
@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void {
window.$events.listen<EditorEventContent>('editor::focus', () => {
focusEditor(editor);
});
let changeFromLoading = true;
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
// Emit change event to component system (for draft detection) on actual user content change
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
if (changeFromLoading) {
changeFromLoading = false;
} else {
window.$events.emit('editor-html-change', '');
}
}
});
}

View File

@ -3,7 +3,7 @@ import {
$createParagraphNode,
$getSelection,
$isDecoratorNode,
COMMAND_PRIORITY_LOW,
COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
@ -13,9 +13,10 @@ import {
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {getLastSelection} from "../utils/selection";
import {$getNearestNodeBlockParent} from "../utils/nodes";
import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes";
import {$setInsetForSelection} from "../utils/lists";
import {$isListItemNode} from "@lexical/list";
import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
if (nodes.length === 1) {
@ -28,6 +29,10 @@ function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
return false;
}
/**
* Delete the current node in the selection if the selection contains a single
* selected node (like image, media etc...).
*/
function deleteSingleSelectedNode(editor: LexicalEditor) {
const selectionNodes = getLastSelection(editor)?.getNodes() || [];
if (isSingleSelectedNode(selectionNodes)) {
@ -37,6 +42,10 @@ function deleteSingleSelectedNode(editor: LexicalEditor) {
}
}
/**
* Insert a new empty node after the selection if the selection contains a single
* selected node (like image, media etc...).
*/
function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
const selectionNodes = getLastSelection(editor)?.getNodes() || [];
if (isSingleSelectedNode(selectionNodes)) {
@ -58,11 +67,108 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve
return false;
}
/**
* Insert a new node after a details node, if inside a details node that's
* the last element, and if the cursor is at the last block within the details node.
*/
function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
const scenario = getDetailsScenario(editor);
if (scenario === null || scenario.detailsSibling) {
return false;
}
editor.update(() => {
const newParagraph = $createParagraphNode();
scenario.parentDetails.insertAfter(newParagraph);
newParagraph.select();
});
event?.preventDefault();
return true;
}
/**
* If within a details block, move after it, creating a new node if required, if we're on
* the last empty block element within the details node.
*/
function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
const scenario = getDetailsScenario(editor);
if (scenario === null) {
return false;
}
if (scenario.parentBlock.getTextContent() !== '') {
return false;
}
event?.preventDefault()
const nextSibling = scenario.parentDetails.getNextSibling();
editor.update(() => {
if (nextSibling) {
nextSibling.selectStart();
} else {
const newParagraph = $createParagraphNode();
scenario.parentDetails.insertAfter(newParagraph);
newParagraph.select();
}
scenario.parentBlock.remove();
});
return true;
}
/**
* Get the common nodes used for a details node scenario, relative to current selection.
* Returns null if not found, or if the parent block is not the last in the parent details node.
*/
function getDetailsScenario(editor: LexicalEditor): {
parentDetails: DetailsNode;
parentBlock: LexicalNode;
detailsSibling: LexicalNode | null
} | null {
const selection = getLastSelection(editor);
const firstNode = selection?.getNodes()[0];
if (!firstNode) {
return null;
}
const block = $getNearestNodeBlockParent(firstNode);
const details = $getParentOfType(firstNode, $isDetailsNode);
if (!$isDetailsNode(details) || block === null) {
return null;
}
if (block.getKey() !== details.getLastChild()?.getKey()) {
return null;
}
const nextSibling = details.getNextSibling();
return {
parentDetails: details,
parentBlock: block,
detailsSibling: nextSibling,
}
}
function $isSingleListItem(nodes: LexicalNode[]): boolean {
if (nodes.length !== 1) {
return false;
}
const node = nodes[0];
return $isListItemNode(node) || $isListItemNode(node.getParent());
}
/**
* Inset the nodes within selection when a range of nodes is selected
* or if a list node is selected.
*/
function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
const change = event?.shiftKey ? -40 : 40;
const selection = $getSelection();
const nodes = selection?.getNodes() || [];
if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) {
if (nodes.length > 1 || $isSingleListItem(nodes)) {
editor.update(() => {
$setInsetForSelection(editor, change);
});
@ -85,17 +191,23 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
}, COMMAND_PRIORITY_LOW);
const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
return insertAfterSingleSelectedNode(context.editor, event);
return insertAfterSingleSelectedNode(context.editor, event)
|| moveAfterDetailsOnEmptyLine(context.editor, event);
}, COMMAND_PRIORITY_LOW);
const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
return handleInsetOnTab(context.editor, event);
}, COMMAND_PRIORITY_LOW);
const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
return insertAfterDetails(context.editor, event);
}, COMMAND_PRIORITY_LOW);
return () => {
unregisterBackspace();
unregisterDelete();
unregisterEnter();
unregisterTab();
unregisterDown();
};
}

View File

@ -11,8 +11,9 @@ import {
} from "lexical";
import redoIcon from "@icons/editor/redo.svg";
import sourceIcon from "@icons/editor/source-view.svg";
import {getEditorContentAsHtml} from "../../../utils/actions";
import fullscreenIcon from "@icons/editor/fullscreen.svg";
import aboutIcon from "@icons/editor/about.svg";
import {getEditorContentAsHtml} from "../../../utils/actions";
export const undo: EditorButtonDefinition = {
label: 'Undo',
@ -80,4 +81,16 @@ export const fullscreen: EditorButtonDefinition = {
isActive(selection, context: EditorUiContext) {
return context.containerDOM.classList.contains('fullscreen');
}
};
export const about: EditorButtonDefinition = {
label: 'About the editor',
icon: aboutIcon,
async action(context: EditorUiContext, button: EditorButton) {
const modal = context.manager.createModal('about');
modal.show({});
},
isActive(selection, context: EditorUiContext) {
return false;
}
};

View File

@ -19,6 +19,9 @@ import editIcon from "@icons/edit.svg";
import diagramIcon from "@icons/editor/diagram.svg";
import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
import detailsIcon from "@icons/editor/details.svg";
import detailsToggleIcon from "@icons/editor/details-toggle.svg";
import tableDeleteIcon from "@icons/editor/table-delete.svg";
import tagIcon from "@icons/tag.svg";
import mediaIcon from "@icons/editor/media.svg";
import {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode";
@ -29,7 +32,7 @@ import {
} from "../../../utils/selection";
import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams";
import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images";
import {$showImageForm, $showLinkForm} from "../forms/objects";
import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects";
import {formatCodeBlock} from "../../../utils/formats";
export const link: EditorButtonDefinition = {
@ -216,4 +219,58 @@ export const details: EditorButtonDefinition = {
isActive(selection: BaseSelection | null): boolean {
return $selectionContainsNodeType(selection, $isDetailsNode);
}
}
export const detailsEditLabel: EditorButtonDefinition = {
label: 'Edit label',
icon: tagIcon,
action(context: EditorUiContext) {
context.editor.getEditorState().read(() => {
const details = $getNodeFromSelection($getSelection(), $isDetailsNode);
if ($isDetailsNode(details)) {
$showDetailsForm(details, context);
}
})
},
isActive(selection: BaseSelection | null): boolean {
return false;
}
}
export const detailsToggle: EditorButtonDefinition = {
label: 'Toggle open/closed',
icon: detailsToggleIcon,
action(context: EditorUiContext) {
context.editor.update(() => {
const details = $getNodeFromSelection($getSelection(), $isDetailsNode);
if ($isDetailsNode(details)) {
details.setOpen(!details.getOpen());
context.manager.triggerLayoutUpdate();
}
})
},
isActive(selection: BaseSelection | null): boolean {
return false;
}
}
export const detailsUnwrap: EditorButtonDefinition = {
label: 'Unwrap',
icon: tableDeleteIcon,
action(context: EditorUiContext) {
context.editor.update(() => {
const details = $getNodeFromSelection($getSelection(), $isDetailsNode);
if ($isDetailsNode(details)) {
const children = details.getChildren();
for (const child of children) {
details.insertBefore(child);
}
details.remove();
context.manager.triggerLayoutUpdate();
}
})
},
isActive(selection: BaseSelection | null): boolean {
return false;
}
}

View File

@ -1,6 +1,7 @@
import {EditorFormDefinition} from "../../framework/forms";
import {EditorUiContext} from "../../framework/core";
import {EditorUiContext, EditorUiElement} from "../../framework/core";
import {setEditorContentFromHtml} from "../../../utils/actions";
import {ExternalContent} from "../../framework/blocks/external-content";
export const source: EditorFormDefinition = {
submitText: 'Save',
@ -15,4 +16,18 @@ export const source: EditorFormDefinition = {
type: 'textarea',
},
],
};
export const about: EditorFormDefinition = {
submitText: 'Close',
async action() {
return true;
},
fields: [
{
build(): EditorUiElement {
return new ExternalContent('/help/wysiwyg');
}
}
],
};

View File

@ -19,6 +19,7 @@ import searchIcon from "@icons/search.svg";
import {showLinkSelector} from "../../../utils/links";
import {LinkField} from "../../framework/blocks/link-field";
import {insertOrUpdateLink} from "../../../utils/formats";
import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
export function $showImageForm(image: ImageNode, context: EditorUiContext) {
const imageModal: EditorFormModal = context.manager.createModal('image');
@ -262,4 +263,37 @@ export const media: EditorFormDefinition = {
}
},
],
};
export function $showDetailsForm(details: DetailsNode|null, context: EditorUiContext) {
const linkModal = context.manager.createModal('details');
if (!details) {
return;
}
linkModal.show({
summary: details.getSummary()
});
}
export const details: EditorFormDefinition = {
submitText: 'Save',
async action(formData, context: EditorUiContext) {
context.editor.update(() => {
const node = $getNodeFromSelection($getSelection(), $isDetailsNode);
const summary = (formData.get('summary') || '').toString().trim();
if ($isDetailsNode(node)) {
node.setSummary(summary);
}
});
return true;
},
fields: [
{
label: 'Toggle label',
name: 'summary',
type: 'text',
},
],
};

View File

@ -1,6 +1,6 @@
import {EditorFormModalDefinition} from "../framework/modals";
import {image, link, media} from "./forms/objects";
import {source} from "./forms/controls";
import {details, image, link, media} from "./forms/objects";
import {about, source} from "./forms/controls";
import {cellProperties, rowProperties, tableProperties} from "./forms/tables";
export const modals: Record<string, EditorFormModalDefinition> = {
@ -32,4 +32,12 @@ export const modals: Record<string, EditorFormModalDefinition> = {
title: 'Table Properties',
form: tableProperties,
},
details: {
title: 'Edit collapsible block',
form: details,
},
about: {
title: 'About the WYSIWYG Editor',
form: about,
}
};

View File

@ -1,12 +1,12 @@
import {EditorButton} from "./framework/buttons";
import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core";
import {EditorFormatMenu} from "./framework/blocks/format-menu";
import {FormatPreviewButton} from "./framework/blocks/format-preview-button";
import {EditorDropdownButton} from "./framework/blocks/dropdown-button";
import {EditorColorPicker} from "./framework/blocks/color-picker";
import {EditorTableCreator} from "./framework/blocks/table-creator";
import {EditorColorButton} from "./framework/blocks/color-button";
import {EditorOverflowContainer} from "./framework/blocks/overflow-container";
import {EditorButton} from "../framework/buttons";
import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "../framework/core";
import {EditorFormatMenu} from "../framework/blocks/format-menu";
import {FormatPreviewButton} from "../framework/blocks/format-preview-button";
import {EditorDropdownButton} from "../framework/blocks/dropdown-button";
import {EditorColorPicker} from "../framework/blocks/color-picker";
import {EditorTableCreator} from "../framework/blocks/table-creator";
import {EditorColorButton} from "../framework/blocks/color-button";
import {EditorOverflowContainer} from "../framework/blocks/overflow-container";
import {
cellProperties, clearTableFormatting,
copyColumn,
@ -29,8 +29,8 @@ import {
rowProperties,
splitCell,
table, tableProperties
} from "./defaults/buttons/tables";
import {fullscreen, redo, source, undo} from "./defaults/buttons/controls";
} from "./buttons/tables";
import {about, fullscreen, redo, source, undo} from "./buttons/controls";
import {
blockquote, dangerCallout,
h2,
@ -41,7 +41,7 @@ import {
paragraph,
successCallout,
warningCallout
} from "./defaults/buttons/block-formats";
} from "./buttons/block-formats";
import {
bold, clearFormating, code,
highlightColor,
@ -50,7 +50,7 @@ import {
superscript,
textColor,
underline
} from "./defaults/buttons/inline-formats";
} from "./buttons/inline-formats";
import {
alignCenter,
alignJustify,
@ -58,27 +58,27 @@ import {
alignRight,
directionLTR,
directionRTL
} from "./defaults/buttons/alignments";
} from "./buttons/alignments";
import {
bulletList,
indentDecrease,
indentIncrease,
numberList,
taskList
} from "./defaults/buttons/lists";
} from "./buttons/lists";
import {
codeBlock,
details,
details, detailsEditLabel, detailsToggle, detailsUnwrap,
diagram, diagramManager,
editCodeBlock,
horizontalRule,
image,
link, media,
unlink
} from "./defaults/buttons/objects";
import {el} from "../utils/dom";
import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu";
import {EditorSeparator} from "./framework/blocks/separator";
} from "./buttons/objects";
import {el} from "../../utils/dom";
import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu";
import {EditorSeparator} from "../framework/blocks/separator";
export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement {
@ -149,8 +149,8 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
new EditorOverflowContainer(4, [
new EditorButton(link),
new EditorDropdownButton({button: table, direction: 'vertical'}, [
new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true}, [
new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [
new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true, showAside: true}, [
new EditorTableCreator(),
]),
new EditorSeparator(),
@ -201,6 +201,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
// Meta elements
new EditorOverflowContainer(3, [
new EditorButton(source),
new EditorButton(about),
new EditorButton(fullscreen),
// Test
@ -253,4 +254,12 @@ export function getTableToolbarContent(): EditorUiElement[] {
new EditorButton(deleteColumn),
]),
];
}
export function getDetailsToolbarContent(): EditorUiElement[] {
return [
new EditorButton(detailsEditLabel),
new EditorButton(detailsToggle),
new EditorButton(detailsUnwrap),
];
}

View File

@ -16,6 +16,7 @@ export class EditorButtonWithMenu extends EditorContainerUiElement {
button: {label: 'Menu', icon: caretDownIcon},
showOnHover: false,
direction: 'vertical',
showAside: false,
}, menuItems);
this.addChildren(this.dropdownButton);
}

View File

@ -7,12 +7,14 @@ import {EditorMenuButton} from "./menu-button";
export type EditorDropdownButtonOptions = {
showOnHover?: boolean;
direction?: 'vertical'|'horizontal';
showAside?: boolean;
button: EditorBasicButtonDefinition|EditorButton;
};
const defaultOptions: EditorDropdownButtonOptions = {
showOnHover: false,
direction: 'horizontal',
showAside: undefined,
button: {label: 'Menu'},
}
@ -65,6 +67,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
handleDropdown({toggle: button, menu : menu,
showOnHover: this.options.showOnHover,
showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'),
onOpen : () => {
this.open = true;
this.getContext().manager.triggerStateUpdateForElement(this.button);

View File

@ -0,0 +1,29 @@
import {EditorUiElement} from "../core";
import {el} from "../../../utils/dom";
export class ExternalContent extends EditorUiElement {
/**
* The URL for HTML to be loaded from.
*/
protected url: string = '';
constructor(url: string) {
super();
this.url = url;
}
buildDOM(): HTMLElement {
const wrapper = el('div', {
class: 'editor-external-content',
});
window.$http.get(this.url).then(resp => {
if (typeof resp.data === 'string') {
wrapper.innerHTML = resp.data;
}
});
return wrapper;
}
}

View File

@ -1,20 +1,48 @@
interface HandleDropdownParams {
toggle: HTMLElement;
menu: HTMLElement;
showOnHover?: boolean,
onOpen?: Function | undefined;
onClose?: Function | undefined;
showAside?: boolean;
}
function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean) {
const toggleRect = toggle.getBoundingClientRect();
const menuBounds = menu.getBoundingClientRect();
menu.style.position = 'fixed';
if (showAside) {
let targetLeft = toggleRect.right;
const isRightOOB = toggleRect.right + menuBounds.width > window.innerWidth;
if (isRightOOB) {
targetLeft = Math.max(toggleRect.left - menuBounds.width, 0);
}
menu.style.top = toggleRect.top + 'px';
menu.style.left = targetLeft + 'px';
} else {
const isRightOOB = toggleRect.left + menuBounds.width > window.innerWidth;
let targetLeft = toggleRect.left;
if (isRightOOB) {
targetLeft = Math.max(toggleRect.right - menuBounds.width, 0);
}
menu.style.top = toggleRect.bottom + 'px';
menu.style.left = targetLeft + 'px';
}
}
export function handleDropdown(options: HandleDropdownParams) {
const {menu, toggle, onClose, onOpen, showOnHover} = options;
const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options;
let clickListener: Function|null = null;
const hide = () => {
menu.hidden = true;
menu.style.removeProperty('position');
menu.style.removeProperty('left');
menu.style.removeProperty('top');
if (clickListener) {
window.removeEventListener('click', clickListener as EventListener);
}
@ -25,6 +53,7 @@ export function handleDropdown(options: HandleDropdownParams) {
const show = () => {
menu.hidden = false
positionMenu(menu, toggle, Boolean(showAside));
clickListener = (event: MouseEvent) => {
if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) {
hide();
@ -44,5 +73,18 @@ export function handleDropdown(options: HandleDropdownParams) {
toggle.addEventListener('mouseenter', toggleShowing);
}
menu.parentElement?.addEventListener('mouseleave', hide);
menu.parentElement?.addEventListener('mouseleave', (event: MouseEvent) => {
// Prevent mouseleave hiding if withing the same bounds of the toggle.
// Avoids hiding in the event the mouse is interrupted by a high z-index
// item like a browser scrollbar.
const toggleBounds = toggle.getBoundingClientRect();
const withinX = event.clientX <= toggleBounds.right && event.clientX >= toggleBounds.left;
const withinY = event.clientY <= toggleBounds.bottom && event.clientY >= toggleBounds.top;
const withinToggle = withinX && withinY;
if (!withinToggle) {
hide();
}
});
}

View File

@ -1,7 +1,7 @@
import {EditorFormModal, EditorFormModalDefinition} from "./modals";
import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
import {BaseSelection, LexicalEditor} from "lexical";
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
import {DecoratorListener} from "lexical/LexicalEditor";
import type {NodeKey} from "lexical/LexicalNode";
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
@ -231,6 +231,22 @@ export class EditorUIManager {
});
}
editor.registerDecoratorListener(domDecorateListener);
// Watch for changes to update local state
editor.registerUpdateListener(({editorState, prevEditorState}) => {
// Watch for selection changes to update the UI on change
// Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
// for all selection changes, so this proved more reliable.
const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
if (selectionChange) {
editor.update(() => {
const selection = $getSelection();
this.triggerStateUpdate({
editor, selection,
});
});
}
});
}
protected setupEventListeners(context: EditorUiContext) {

View File

@ -1,10 +1,10 @@
import {LexicalEditor} from "lexical";
import {
getCodeToolbarContent,
getCodeToolbarContent, getDetailsToolbarContent,
getImageToolbarContent,
getLinkToolbarContent,
getMainEditorFullToolbar, getTableToolbarContent
} from "./toolbars";
} from "./defaults/toolbars";
import {EditorUIManager} from "./framework/manager";
import {EditorUiContext} from "./framework/core";
import {CodeBlockDecorator} from "./decorators/code-block";
@ -56,7 +56,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
selector: '.editor-code-block-wrap',
content: getCodeToolbarContent(),
});
manager.registerContextToolbar('table', {
selector: 'td,th',
content: getTableToolbarContent(),
@ -64,6 +63,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
return originalTarget.closest('table') as HTMLTableElement;
}
});
manager.registerContextToolbar('details', {
selector: 'details',
content: getDetailsToolbarContent(),
});
// Register image decorator listener
manager.registerDecoratorType('code', CodeBlockDecorator);

View File

@ -0,0 +1,124 @@
import {
createTestContext, destroyFromContext,
dispatchKeydownEventForNode, expectNodeShapeToMatch,
} from "lexical/__tests__/utils";
import {
$createParagraphNode, $getRoot, LexicalEditor, LexicalNode,
ParagraphNode,
} from "lexical";
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {EditorUiContext} from "../../ui/framework/core";
import {$htmlToBlockNodes} from "../nodes";
import {ListItemNode, ListNode} from "@lexical/list";
import {$nestListItem, $unnestListItem} from "../lists";
describe('List Utils', () => {
let context!: EditorUiContext;
let editor!: LexicalEditor;
beforeEach(() => {
context = createTestContext();
editor = context.editor;
});
afterEach(() => {
destroyFromContext(context);
});
describe('$nestListItem', () => {
test('nesting handles child items to leave at the same level', () => {
const input = `<ul>
<li>Inner A</li>
<li>Inner B <ul>
<li>Inner C</li>
</ul></li>
</ul>`;
let list!: ListNode;
editor.updateAndCommit(() => {
$getRoot().append(...$htmlToBlockNodes(editor, input));
list = $getRoot().getFirstChild() as ListNode;
});
editor.updateAndCommit(() => {
$nestListItem(list.getChildren()[1] as ListItemNode);
});
expectNodeShapeToMatch(editor, [
{
type: 'list',
children: [
{
type: 'listitem',
children: [
{text: 'Inner A'},
{
type: 'list',
children: [
{type: 'listitem', children: [{text: 'Inner B'}]},
{type: 'listitem', children: [{text: 'Inner C'}]},
]
}
]
},
]
}
]);
});
});
describe('$unnestListItem', () => {
test('middle in nested list converts to new parent item at same place', () => {
const input = `<ul>
<li>Nested list:<ul>
<li>Inner A</li>
<li>Inner B</li>
<li>Inner C</li>
</ul></li>
</ul>`;
let innerList!: ListNode;
editor.updateAndCommit(() => {
$getRoot().append(...$htmlToBlockNodes(editor, input));
innerList = (($getRoot().getFirstChild() as ListNode).getFirstChild() as ListItemNode).getLastChild() as ListNode;
});
editor.updateAndCommit(() => {
$unnestListItem(innerList.getChildren()[1] as ListItemNode);
});
expectNodeShapeToMatch(editor, [
{
type: 'list',
children: [
{
type: 'listitem',
children: [
{text: 'Nested list:'},
{
type: 'list',
children: [
{type: 'listitem', children: [{text: 'Inner A'}]},
],
}
],
},
{
type: 'listitem',
children: [
{text: 'Inner B'},
{
type: 'list',
children: [
{type: 'listitem', children: [{text: 'Inner C'}]},
],
}
],
}
]
}
]);
});
});
});

View File

@ -1,4 +1,4 @@
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical";
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
import {nodeHasInset} from "./nodes";
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
return node;
}
const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null;
const nodeChildItems = nodeChildList?.getChildren() || [];
const listItems = list.getChildren() as ListItemNode[];
const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
const isFirst = nodeIndex === 0;
@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode {
node.remove();
}
if (nodeChildList) {
for (const child of nodeChildItems) {
newListItem.insertAfter(child);
}
nodeChildList.remove();
}
return newListItem;
}
@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
return node;
}
const laterSiblings = node.getNextSiblings();
parentListItem.insertAfter(node);
if (list.getChildren().length === 0) {
list.remove();
@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode {
parentListItem.remove();
}
if (laterSiblings.length > 0) {
const childList = $createListNode(list.getListType());
childList.append(...laterSiblings);
node.append(childList);
}
if (list.getChildrenSize() === 0) {
list.remove();
}
return node;
}
@ -93,6 +115,7 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[
export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
const selection = $getSelection();
const selectionBounds = selection?.getStartEndPoints();
const listItemsInSelection = getListItemsForSelection(selection);
const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
@ -110,7 +133,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo
alteredListItems.reverse();
}
$selectNodes(alteredListItems);
if (alteredListItems.length === 1 && selectionBounds) {
// Retain selection range if moving just one item
const listItem = alteredListItems[0] as ListItemNode;
let child = listItem.getChildren()[0] as TextNode;
if (!child) {
child = $createTextNode('');
listItem.append(child);
}
child.select(selectionBounds[0].offset, selectionBounds[1].offset);
} else {
$selectNodes(alteredListItems);
}
return;
}

View File

@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher:
return null;
}
export function $getTextNodeFromSelection(selection: BaseSelection | null): TextNode|null {
return $getNodeFromSelection(selection, $isTextNode) as TextNode|null;
}
export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean {
if (!selection) {
return false;

View File

@ -8,18 +8,28 @@
// Main UI elements
.editor-container {
background-color: #FFF;
@include mixins.lightDark(background-color, #FFF, #222);
position: relative;
&.fullscreen {
z-index: 500;
}
}
.editor-toolbar-main {
display: flex;
flex-wrap: wrap;
justify-content: center;
border-top: 1px solid #DDD;
border-bottom: 1px solid #DDD;
@include mixins.lightDark(border-color, #DDD, #000);
}
@include mixins.smaller-than(vars.$bp-xl) {
.editor-toolbar-main {
overflow-x: scroll;
flex-wrap: nowrap;
justify-content: start;
}
}
body.editor-is-fullscreen {
@ -38,6 +48,7 @@ body.editor-is-fullscreen {
.editor-content-wrap {
position: relative;
overflow-y: scroll;
padding-inline: vars.$s;
flex: 1;
}
@ -46,6 +57,7 @@ body.editor-is-fullscreen {
font-size: 12px;
padding: 4px;
color: #444;
@include mixins.lightDark(color, #444, #999);
border-radius: 4px;
display: flex;
align-items: center;
@ -54,6 +66,7 @@ body.editor-is-fullscreen {
}
.editor-button:hover {
background-color: #EEE;
@include mixins.lightDark(background-color, #EEE, #333);
cursor: pointer;
color: #000;
}
@ -63,7 +76,7 @@ body.editor-is-fullscreen {
opacity: .6;
}
.editor-button-active, .editor-button-active:hover {
background-color: #ceebff;
@include mixins.lightDark(background-color, #ceebff, #444);
color: #000;
}
.editor-button-long {
@ -75,7 +88,7 @@ body.editor-is-fullscreen {
}
.editor-button-text {
font-weight: 400;
color: #000;
@include mixins.lightDark(color, #000, #AAA);
font-size: 14px;
flex: 1;
padding-inline-end: 4px;
@ -126,7 +139,8 @@ body.editor-is-fullscreen {
}
}
&:hover {
outline: 1px solid #DDD;
outline: 1px solid;
@include mixins.lightDark(outline-color, #DDD, #111);
outline-offset: -3px;
}
}
@ -137,11 +151,14 @@ body.editor-is-fullscreen {
}
.editor-dropdown-menu {
position: absolute;
background-color: #FFF;
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15);
border: 1px solid;
@include mixins.lightDark(background-color, #FFF, #292929);
@include mixins.lightDark(border-color, #FFF, #333);
@include mixins.lightDark(box-shadow, 0 0 6px 0 rgba(0, 0, 0, 0.15), 0 1px 4px 0 rgba(0, 0, 0, 0.4));
z-index: 99;
display: flex;
flex-direction: row;
border-radius: 3px;
}
.editor-dropdown-menu-vertical {
display: flex;
@ -163,8 +180,8 @@ body.editor-is-fullscreen {
.editor-separator {
display: block;
height: 1px;
background-color: #DDD;
opacity: .8;
@include mixins.lightDark(background-color, #DDD, #000);
}
.editor-format-menu-toggle {
@ -199,6 +216,7 @@ body.editor-is-fullscreen {
display: flex;
border-inline: 1px solid #DDD;
padding-inline: 4px;
@include mixins.lightDark(border-color, #DDD, #000);
&:first-child {
border-inline-start: none;
}
@ -212,11 +230,12 @@ body.editor-is-fullscreen {
.editor-context-toolbar {
position: fixed;
background-color: #FFF;
border: 1px solid #DDD;
@include mixins.lightDark(background-color, #FFF, #222);
@include mixins.lightDark(border-color, #DDD, #333);
@include mixins.lightDark(box-shadow, 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 1px 4px 0 rgba(0, 0, 0, 0.4));
padding: .2rem;
border-radius: 4px;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: row;
&:before {
@ -226,9 +245,10 @@ body.editor-is-fullscreen {
width: 8px;
height: 8px;
position: absolute;
background-color: #FFF;
@include mixins.lightDark(background-color, #FFF, #222);
border-top: 1px solid #DDD;
border-left: 1px solid #DDD;
@include mixins.lightDark(border-color, #DDD, #333);
transform: rotate(45deg);
left: 50%;
margin-left: -4px;
@ -252,10 +272,13 @@ body.editor-is-fullscreen {
height: 100%;
}
.editor-modal {
background-color: #FFF;
@include mixins.lightDark(background-color, #FFF, #222);
border-radius: 4px;
overflow: hidden;
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3);
margin: vars.$xs;
max-height: 100%;
overflow-y: auto;
}
.editor-modal-header {
display: flex;
@ -314,7 +337,8 @@ body.editor-is-fullscreen {
display: flex;
}
.editor-table-creator-cell {
border: 1px solid #DDD;
border: 1px solid;
@include mixins.lightDark(border-color, #DDD, #000);
width: 15px;
height: 15px;
cursor: pointer;
@ -326,6 +350,13 @@ body.editor-is-fullscreen {
text-align: center;
padding: 0.2em;
}
.editor-external-content {
min-width: 500px;
min-height: 500px;
h4:first-child {
margin-top: 0;
}
}
// In-editor elements
.editor-image-wrap {
@ -347,7 +378,7 @@ body.editor-is-fullscreen {
height: 10px;
border: 2px solid var(--editor-color-primary);
z-index: 3;
background-color: #FFF;
@include mixins.lightDark(background-color, #FFF, #000);
user-select: none;
&.nw {
inset-inline-start: -5px;
@ -470,18 +501,29 @@ body.editor-is-fullscreen {
/**
* Form elements
*/
$inputWidth: 260px;
.editor-form-field-wrapper {
margin-bottom: .5rem;
}
.editor-form-field-input {
display: block;
width: 100%;
min-width: 250px;
border: 1px solid #DDD;
width: $inputWidth;
min-width: 100px;
max-width: 100%;
border: 1px solid;
@include mixins.lightDark(border-color, #DDD, #000);
padding: .5rem;
border-radius: 4px;
color: #444;
@include mixins.lightDark(color, #444, #BBB);
}
@include mixins.smaller-than(vars.$bp-xs) {
.editor-form-field-input {
min-width: 160px;
}
}
textarea.editor-form-field-input {
font-family: var(--font-code);
width: 350px;
@ -554,10 +596,21 @@ textarea.editor-form-field-input {
align-items: stretch;
gap: .25rem;
}
@include mixins.smaller-than(vars.$bp-m) {
.editor-form-tab-container {
flex-direction: column;
gap: .5rem;
}
.editor-form-tab-controls {
flex-direction: row;
}
}
.editor-form-tab-control {
font-weight: bold;
font-size: 14px;
color: #444;
@include mixins.lightDark(color, #444, #666);
border-bottom: 2px solid transparent;
position: relative;
cursor: pointer;
@ -565,7 +618,7 @@ textarea.editor-form-field-input {
text-align: start;
&[aria-selected="true"] {
border-color: var(--editor-color-primary);
color: var(--editor-color-primary);
color: var(--editor-color-primary) !important;
}
&[aria-selected="true"]:after, &:hover:after {
background-color: var(--editor-color-primary);
@ -580,7 +633,8 @@ textarea.editor-form-field-input {
}
}
.editor-form-tab-contents {
width: 360px;
width: $inputWidth;
max-width: 100%;
}
.editor-action-input-container {
display: flex;
@ -591,6 +645,9 @@ textarea.editor-form-field-input {
.editor-button {
margin-bottom: 12px;
}
input {
width: $inputWidth - 40px;
}
}
// Editor theme styles

View File

@ -26,6 +26,7 @@
width: 100%;
border-radius: 8px;
box-shadow: vars.$bs-card;
min-width: 300px;
@include mixins.lightDark(background-color, #FFF, #333)
}

View File

@ -0,0 +1,146 @@
@extends('layouts.plain')
@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : ''))
@section('content')
<div class="p-m">
<h4 class="mt-s">{{ trans('editor.editor_license') }}</h4>
<p>
{!! trans('editor.editor_tiny_license', ['tinyLink' => '<a href="https://www.tiny.cloud/" target="_blank" rel="noopener noreferrer">TinyMCE</a>']) !!}
<br>
<a href="{{ url('/libs/tinymce/license.txt') }}" target="_blank">{{ trans('editor.editor_tiny_license_link') }}</a>
</p>
<h4>{{ trans('editor.shortcuts') }}</h4>
<p>{{ trans('editor.shortcuts_intro') }}</p>
<table>
<thead>
<tr>
<th>{{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}</th>
<th>{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}</th>
<th>{{ trans('editor.description') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Ctrl</code>+<code>S</code></td>
<td><code>Cmd</code>+<code>S</code></td>
<td>{{ trans('entities.pages_edit_save_draft') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>Enter</code></td>
<td><code>Cmd</code>+<code>Enter</code></td>
<td>{{ trans('editor.save_continue') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>B</code></td>
<td><code>Cmd</code>+<code>B</code></td>
<td>{{ trans('editor.bold') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>I</code></td>
<td><code>Cmd</code>+<code>I</code></td>
<td>{{ trans('editor.italic') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>1</code><br>
<code>Ctrl</code>+<code>2</code><br>
<code>Ctrl</code>+<code>3</code><br>
<code>Ctrl</code>+<code>4</code>
</td>
<td>
<code>Cmd</code>+<code>1</code><br>
<code>Cmd</code>+<code>2</code><br>
<code>Cmd</code>+<code>3</code><br>
<code>Cmd</code>+<code>4</code>
</td>
<td>
{{ trans('editor.header_large') }} <br>
{{ trans('editor.header_medium') }} <br>
{{ trans('editor.header_small') }} <br>
{{ trans('editor.header_tiny') }}
</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>5</code><br>
<code>Ctrl</code>+<code>D</code>
</td>
<td>
<code>Cmd</code>+<code>5</code><br>
<code>Cmd</code>+<code>D</code>
</td>
<td>{{ trans('editor.paragraph') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>6</code><br>
<code>Ctrl</code>+<code>Q</code>
</td>
<td>
<code>Cmd</code>+<code>6</code><br>
<code>Cmd</code>+<code>Q</code>
</td>
<td>{{ trans('editor.blockquote') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>7</code><br>
<code>Ctrl</code>+<code>E</code>
</td>
<td>
<code>Cmd</code>+<code>7</code><br>
<code>Cmd</code>+<code>E</code>
</td>
<td>{{ trans('editor.insert_code_block') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>8</code><br>
<code>Ctrl</code>+<code>Shift</code>+<code>E</code>
</td>
<td>
<code>Cmd</code>+<code>8</code><br>
<code>Cmd</code>+<code>Shift</code>+<code>E</code>
</td>
<td>{{ trans('editor.inline_code') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>9</code></td>
<td><code>Cmd</code>+<code>9</code></td>
<td>
{{ trans('editor.callouts') }} <br>
<small>{{ trans('editor.callouts_cycle') }}</small>
</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>O</code> <br>
<code>Ctrl</code>+<code>P</code>
</td>
<td>
<code>Cmd</code>+<code>O</code> <br>
<code>Cmd</code>+<code>P</code>
</td>
<td>
{{ trans('editor.list_numbered') }} <br>
{{ trans('editor.list_bullet') }}
</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>Shift</code>+<code>K</code>
</td>
<td>
<code>Cmd</code>+<code>Shift</code>+<code>K</code>
</td>
<td>{{ trans('editor.link_selector') }}</td>
</tr>
</tbody>
</table>
</div>
@endsection

View File

@ -1,146 +1,138 @@
@extends('layouts.plain')
@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : ''))
<h4>{{ trans('editor.shortcuts') }}</h4>
@section('content')
<div class="p-m">
<h4 class="mt-s">{{ trans('editor.editor_license') }}</h4>
<p>
{!! trans('editor.editor_tiny_license', ['tinyLink' => '<a href="https://www.tiny.cloud/" target="_blank" rel="noopener noreferrer">TinyMCE</a>']) !!}
<br>
<a href="{{ url('/libs/tinymce/license.txt') }}" target="_blank">{{ trans('editor.editor_tiny_license_link') }}</a>
</p>
<h4>{{ trans('editor.shortcuts') }}</h4>
<p>{{ trans('editor.shortcuts_intro') }}</p>
<table>
<thead>
<tr>
<th>{{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}</th>
<th>{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}</th>
<th>{{ trans('editor.description') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Ctrl</code>+<code>S</code></td>
<td><code>Cmd</code>+<code>S</code></td>
<td>{{ trans('entities.pages_edit_save_draft') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>Enter</code></td>
<td><code>Cmd</code>+<code>Enter</code></td>
<td>{{ trans('editor.save_continue') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>B</code></td>
<td><code>Cmd</code>+<code>B</code></td>
<td>{{ trans('editor.bold') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>I</code></td>
<td><code>Cmd</code>+<code>I</code></td>
<td>{{ trans('editor.italic') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>1</code><br>
<code>Ctrl</code>+<code>2</code><br>
<code>Ctrl</code>+<code>3</code><br>
<code>Ctrl</code>+<code>4</code>
</td>
<td>
<code>Cmd</code>+<code>1</code><br>
<code>Cmd</code>+<code>2</code><br>
<code>Cmd</code>+<code>3</code><br>
<code>Cmd</code>+<code>4</code>
</td>
<td>
{{ trans('editor.header_large') }} <br>
{{ trans('editor.header_medium') }} <br>
{{ trans('editor.header_small') }} <br>
{{ trans('editor.header_tiny') }}
</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>5</code><br>
<code>Ctrl</code>+<code>D</code>
</td>
<td>
<code>Cmd</code>+<code>5</code><br>
<code>Cmd</code>+<code>D</code>
</td>
<td>{{ trans('editor.paragraph') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>6</code><br>
<code>Ctrl</code>+<code>Q</code>
</td>
<td>
<code>Cmd</code>+<code>6</code><br>
<code>Cmd</code>+<code>Q</code>
</td>
<td>{{ trans('editor.blockquote') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>7</code><br>
<code>Ctrl</code>+<code>E</code>
</td>
<td>
<code>Cmd</code>+<code>7</code><br>
<code>Cmd</code>+<code>E</code>
</td>
<td>{{ trans('editor.insert_code_block') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>8</code><br>
<code>Ctrl</code>+<code>Shift</code>+<code>E</code>
</td>
<td>
<code>Cmd</code>+<code>8</code><br>
<code>Cmd</code>+<code>Shift</code>+<code>E</code>
</td>
<td>{{ trans('editor.inline_code') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>9</code></td>
<td><code>Cmd</code>+<code>9</code></td>
<td>
{{ trans('editor.callouts') }} <br>
<small>{{ trans('editor.callouts_cycle') }}</small>
</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>O</code> <br>
<code>Ctrl</code>+<code>P</code>
</td>
<td>
<code>Cmd</code>+<code>O</code> <br>
<code>Cmd</code>+<code>P</code>
</td>
<td>
{{ trans('editor.list_numbered') }} <br>
{{ trans('editor.list_bullet') }}
</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>Shift</code>+<code>K</code>
</td>
<td>
<code>Cmd</code>+<code>Shift</code>+<code>K</code>
</td>
<td>{{ trans('editor.link_selector') }}</td>
</tr>
</tbody>
</table>
</div>
@endsection
<p>{{ trans('editor.shortcuts_intro') }}</p>
<table>
<thead>
<tr>
<th>{{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}</th>
<th>{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}</th>
<th>{{ trans('editor.description') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Ctrl</code>+<code>S</code></td>
<td><code>Cmd</code>+<code>S</code></td>
<td>{{ trans('entities.pages_edit_save_draft') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>Enter</code></td>
<td><code>Cmd</code>+<code>Enter</code></td>
<td>{{ trans('editor.save_continue') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>B</code></td>
<td><code>Cmd</code>+<code>B</code></td>
<td>{{ trans('editor.bold') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>I</code></td>
<td><code>Cmd</code>+<code>I</code></td>
<td>{{ trans('editor.italic') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>1</code><br>
<code>Ctrl</code>+<code>2</code><br>
<code>Ctrl</code>+<code>3</code><br>
<code>Ctrl</code>+<code>4</code>
</td>
<td>
<code>Cmd</code>+<code>1</code><br>
<code>Cmd</code>+<code>2</code><br>
<code>Cmd</code>+<code>3</code><br>
<code>Cmd</code>+<code>4</code>
</td>
<td>
{{ trans('editor.header_large') }} <br>
{{ trans('editor.header_medium') }} <br>
{{ trans('editor.header_small') }} <br>
{{ trans('editor.header_tiny') }}
</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>5</code><br>
<code>Ctrl</code>+<code>D</code>
</td>
<td>
<code>Cmd</code>+<code>5</code><br>
<code>Cmd</code>+<code>D</code>
</td>
<td>{{ trans('editor.paragraph') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>6</code><br>
<code>Ctrl</code>+<code>Q</code>
</td>
<td>
<code>Cmd</code>+<code>6</code><br>
<code>Cmd</code>+<code>Q</code>
</td>
<td>{{ trans('editor.blockquote') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>7</code><br>
<code>Ctrl</code>+<code>E</code>
</td>
<td>
<code>Cmd</code>+<code>7</code><br>
<code>Cmd</code>+<code>E</code>
</td>
<td>{{ trans('editor.insert_code_block') }}</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>8</code><br>
<code>Ctrl</code>+<code>Shift</code>+<code>E</code>
</td>
<td>
<code>Cmd</code>+<code>8</code><br>
<code>Cmd</code>+<code>Shift</code>+<code>E</code>
</td>
<td>{{ trans('editor.inline_code') }}</td>
</tr>
<tr>
<td><code>Ctrl</code>+<code>9</code></td>
<td><code>Cmd</code>+<code>9</code></td>
<td>
{{ trans('editor.callouts') }} <br>
<small>{{ trans('editor.callouts_cycle') }}</small>
</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>O</code> <br>
<code>Ctrl</code>+<code>P</code>
</td>
<td>
<code>Cmd</code>+<code>O</code> <br>
<code>Cmd</code>+<code>P</code>
</td>
<td>
{{ trans('editor.list_numbered') }} <br>
{{ trans('editor.list_bullet') }}
</td>
</tr>
<tr>
<td>
<code>Ctrl</code>+<code>Shift</code>+<code>K</code>
</td>
<td>
<code>Cmd</code>+<code>Shift</code>+<code>K</code>
</td>
<td>{{ trans('editor.link_selector') }}</td>
</tr>
</tbody>
</table>
<h4 class="mt-s">{{ trans('editor.editor_license') }}</h4>
<p>
{!! trans('editor.editor_lexical_license', ['lexicalLink' => '<a href="https://lexical.dev/" target="_blank" rel="noopener noreferrer">Lexical</a>']) !!}
<br>
<em class="text-muted">Copyright (c) Meta Platforms, Inc. and affiliates.</em>
<br>
<a href="{{ url('/licenses') }}" target="_blank">{{ trans('editor.editor_lexical_license_link') }}</a>
</p>

View File

@ -361,6 +361,7 @@ Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController
Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public');
// Metadata routes
Route::view('/help/tinymce', 'help.tinymce');
Route::view('/help/wysiwyg', 'help.wysiwyg');
Route::fallback([MetaController::class, 'notFound'])->name('fallback');

View File

@ -6,9 +6,9 @@ use Tests\TestCase;
class HelpTest extends TestCase
{
public function test_wysiwyg_help_shows_tiny_and_tiny_license_link()
public function test_tinymce_help_shows_tiny_and_tiny_license_link()
{
$resp = $this->get('/help/wysiwyg');
$resp = $this->get('/help/tinymce');
$resp->assertOk();
$this->withHtml($resp)->assertElementExists('a[href="https://www.tiny.cloud/"]');
$this->withHtml($resp)->assertElementExists('a[href="' . url('/libs/tinymce/license.txt') . '"]');
@ -22,4 +22,12 @@ class HelpTest extends TestCase
$contents = file_get_contents($expectedPath);
$this->assertStringContainsString('MIT License', $contents);
}
public function test_wysiwyg_help_shows_lexical_and_licenses_link()
{
$resp = $this->get('/help/wysiwyg');
$resp->assertOk();
$this->withHtml($resp)->assertElementExists('a[href="https://lexical.dev/"]');
$this->withHtml($resp)->assertElementExists('a[href="' . url('/licenses') . '"]');
}
}