Merge pull request #5365 from BookStackApp/lexical_fixes
Range of fixes/updates for the new Lexical based editor
This commit is contained in:
commit
1f88bc2a59
|
@ -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';
|
||||
},
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 653 B |
|
@ -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 |
|
@ -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', {
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>`);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
})
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
};
|
||||
}
|
|
@ -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', '');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
}
|
|
@ -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',
|
||||
|
@ -81,3 +82,15 @@ export const fullscreen: EditorButtonDefinition = {
|
|||
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;
|
||||
}
|
||||
};
|
|
@ -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 = {
|
||||
|
@ -217,3 +220,57 @@ export const details: EditorButtonDefinition = {
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
|
@ -16,3 +17,17 @@ export const source: EditorFormDefinition = {
|
|||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const about: EditorFormDefinition = {
|
||||
submitText: 'Close',
|
||||
async action() {
|
||||
return true;
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
build(): EditorUiElement {
|
||||
return new ExternalContent('/help/wysiwyg');
|
||||
}
|
||||
}
|
||||
],
|
||||
};
|
|
@ -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');
|
||||
|
@ -263,3 +264,36 @@ 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',
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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,
|
||||
}
|
||||
};
|
|
@ -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
|
||||
|
@ -254,3 +255,11 @@ export function getTableToolbarContent(): EditorUiElement[] {
|
|||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function getDetailsToolbarContent(): EditorUiElement[] {
|
||||
return [
|
||||
new EditorButton(detailsEditLabel),
|
||||
new EditorButton(detailsToggle),
|
||||
new EditorButton(detailsUnwrap),
|
||||
];
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'}]},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
width: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: vars.$bs-card;
|
||||
min-width: 300px;
|
||||
@include mixins.lightDark(background-color, #FFF, #333)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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>
|
|
@ -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');
|
||||
|
|
|
@ -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') . '"]');
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue