mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-07-16 05:33:49 +03:00
Merge branch 'development' into clauvaldez/mfaReset
This commit is contained in:
1
resources/icons/editor/table-header.svg
Normal file
1
resources/icons/editor/table-header.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200m293-240h134v-160H413Zm0 240h134v-160H413ZM200-440h133v-160H200Zm427 0h133v-160H627ZM200-200h133v-160H200Zm427 0h133v-160H627Z"/></svg>
|
||||
|
After Width: | Height: | Size: 327 B |
@@ -19,6 +19,8 @@ function setSummary(editor, summaryContent) {
|
||||
}
|
||||
summary.textContent = summaryContent;
|
||||
});
|
||||
|
||||
editor.selection.select(details);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,8 +204,12 @@ function register(editor) {
|
||||
});
|
||||
|
||||
editor.on('dblclick', event => {
|
||||
if (!getSelectedDetailsBlock(editor) || event.target.closest('doc-root')) return;
|
||||
showDetailLabelEditWindow(editor);
|
||||
const domElClass = event?.target?.ownerDocument?.defaultView?.HTMLDetailsElement;
|
||||
if (domElClass && event.target instanceof domElClass && getSelectedDetailsBlock(editor)) {
|
||||
showDetailLabelEditWindow(editor);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('toggledetails', {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {registerSelectionHandling} from "./services/selection-handling";
|
||||
import {EditorApi} from "./api/api";
|
||||
import {registerMentions} from "./services/mentions";
|
||||
import {MentionDecorator} from "./ui/decorators/MentionDecorator";
|
||||
import {registerLists} from "@lexical/list";
|
||||
|
||||
const theme = {
|
||||
text: {
|
||||
@@ -58,8 +59,9 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
|
||||
|
||||
mergeRegister(
|
||||
registerRichText(editor),
|
||||
registerLists(editor),
|
||||
registerHistory(editor, createEmptyHistoryState(), 300),
|
||||
registerShortcuts(context),
|
||||
registerShortcuts(context, true),
|
||||
registerKeyboardHandling(context),
|
||||
registerMouseHandling(context),
|
||||
registerSelectionHandling(context),
|
||||
@@ -122,8 +124,9 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s
|
||||
|
||||
const editorTeardown = mergeRegister(
|
||||
registerRichText(editor),
|
||||
registerLists(editor),
|
||||
registerHistory(editor, createEmptyHistoryState(), 300),
|
||||
registerShortcuts(context),
|
||||
registerShortcuts(context, false),
|
||||
registerAutoLinks(editor),
|
||||
);
|
||||
|
||||
@@ -157,7 +160,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent:
|
||||
const editorTeardown = mergeRegister(
|
||||
registerRichText(editor),
|
||||
registerHistory(editor, createEmptyHistoryState(), 300),
|
||||
registerShortcuts(context),
|
||||
registerShortcuts(context, false),
|
||||
registerAutoLinks(editor),
|
||||
registerMentions(context),
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
export type PasteCommandType = ClipboardEvent | InputEvent | KeyboardEvent;
|
||||
|
||||
export function createCommand<T>(type?: string): LexicalCommand<T> {
|
||||
return __DEV__ ? {type} : {};
|
||||
return {type};
|
||||
}
|
||||
|
||||
export const SELECTION_CHANGE_COMMAND: LexicalCommand<void> = createCommand(
|
||||
|
||||
@@ -45,6 +45,7 @@ import {LineBreakNode} from './nodes/LexicalLineBreakNode';
|
||||
import {ParagraphNode} from './nodes/LexicalParagraphNode';
|
||||
import {RootNode} from './nodes/LexicalRootNode';
|
||||
import {TabNode} from './nodes/LexicalTabNode';
|
||||
import {EditorUiContext} from "../../ui/framework/core";
|
||||
|
||||
export type Spread<T1, T2> = Omit<T2, keyof T1> & T1;
|
||||
|
||||
@@ -621,6 +622,8 @@ export class LexicalEditor {
|
||||
_editable: boolean;
|
||||
/** @internal */
|
||||
_blockCursorElement: null | HTMLDivElement;
|
||||
/** @internal */
|
||||
_context: null | EditorUiContext;
|
||||
|
||||
/** @internal */
|
||||
constructor(
|
||||
@@ -682,6 +685,7 @@ export class LexicalEditor {
|
||||
this._headless = parentEditor !== null && parentEditor._headless;
|
||||
this._window = null;
|
||||
this._blockCursorElement = null;
|
||||
this._context = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1285,6 +1289,21 @@ export class LexicalEditor {
|
||||
triggerListeners('editable', this, true, editable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the UI context that this editor is intended to be part of.
|
||||
*/
|
||||
setUiContext(context: EditorUiContext) {
|
||||
this._context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UI context that this editor is considered to be part of.
|
||||
*/
|
||||
getUiContext(): EditorUiContext|null {
|
||||
return this._context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a JSON-serializable javascript object NOT a JSON string.
|
||||
* You still must call JSON.stringify (or something else) to turn the
|
||||
|
||||
@@ -99,7 +99,6 @@ import {
|
||||
getNearestEditorFromDOMNode,
|
||||
getWindow, isAt,
|
||||
isBackspace,
|
||||
isBold,
|
||||
isCopy,
|
||||
isCut,
|
||||
isDelete,
|
||||
@@ -111,7 +110,6 @@ import {
|
||||
isDeleteWordForward,
|
||||
isEscape,
|
||||
isFirefoxClipboardEvents,
|
||||
isItalic,
|
||||
isLexicalEditor,
|
||||
isLineBreak,
|
||||
isModifier,
|
||||
@@ -128,7 +126,6 @@ import {
|
||||
isSelectionWithinEditor,
|
||||
isSpace,
|
||||
isTab,
|
||||
isUnderline,
|
||||
isUndo,
|
||||
} from './LexicalUtils';
|
||||
|
||||
@@ -479,7 +476,6 @@ function onClick(event: PointerEvent, editor: LexicalEditor): void {
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent, editor: LexicalEditor) {
|
||||
// TODO implement text drag & drop
|
||||
const target = event.target;
|
||||
const pointerType = event.pointerType;
|
||||
if (target instanceof Node && pointerType !== 'touch') {
|
||||
@@ -1064,15 +1060,6 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
|
||||
dispatchCommand(editor, DELETE_LINE_COMMAND, false);
|
||||
} else if (isAt(key)) {
|
||||
dispatchCommand(editor, KEY_AT_COMMAND, event);
|
||||
} else if (isBold(key, altKey, metaKey, ctrlKey)) {
|
||||
event.preventDefault();
|
||||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
|
||||
} else if (isUnderline(key, altKey, metaKey, ctrlKey)) {
|
||||
event.preventDefault();
|
||||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
|
||||
} else if (isItalic(key, altKey, metaKey, ctrlKey)) {
|
||||
event.preventDefault();
|
||||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
|
||||
} else if (isTab(key, altKey, ctrlKey, metaKey)) {
|
||||
dispatchCommand(editor, KEY_TAB_COMMAND, event);
|
||||
} else if (isUndo(key, shiftKey, metaKey, ctrlKey)) {
|
||||
|
||||
@@ -208,9 +208,9 @@ export class LexicalNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones this node, creating a new node with a different key
|
||||
* and adding it to the EditorState (but not attaching it anywhere!). All nodes must
|
||||
* implement this method.
|
||||
* Clones this node, creating a new matching instance.
|
||||
* Should be created with the existing node key if it exists.
|
||||
* All nodes must implement this method.
|
||||
*
|
||||
*/
|
||||
static clone(_data: unknown): LexicalNode {
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
toggleTextFormatType,
|
||||
} from './LexicalUtils';
|
||||
import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode';
|
||||
import {$selectSingleNode} from "../../utils/selection";
|
||||
|
||||
export type TextPointType = {
|
||||
_selection: BaseSelection;
|
||||
@@ -2220,10 +2219,23 @@ export function $createRangeSelection(): RangeSelection {
|
||||
return new RangeSelection(anchor, focus, 0, '');
|
||||
}
|
||||
|
||||
export function $createCollapsedRangeSelectionForNode(node: LexicalNode, offset: number = 0): RangeSelection {
|
||||
const type = $isTextNode(node) ? 'text' : 'element';
|
||||
const anchor = $createPoint(node.getKey(), offset, type);
|
||||
const focus = $createPoint(node.getKey(), offset, type);
|
||||
return new RangeSelection(anchor, focus, 0, '');
|
||||
}
|
||||
|
||||
export function $createNodeSelection(): NodeSelection {
|
||||
return new NodeSelection(new Set());
|
||||
}
|
||||
|
||||
function $selectSingleNode(node: LexicalNode): void {
|
||||
const nodeSelection = $createNodeSelection();
|
||||
nodeSelection.add(node.getKey());
|
||||
$setSelection(nodeSelection);
|
||||
}
|
||||
|
||||
export function $internalCreateSelection(
|
||||
editor: LexicalEditor,
|
||||
): null | BaseSelection {
|
||||
|
||||
@@ -783,39 +783,6 @@ export function isTab(
|
||||
return key === 'Tab' && !altKey && !ctrlKey && !metaKey;
|
||||
}
|
||||
|
||||
export function isBold(
|
||||
key: string,
|
||||
altKey: boolean,
|
||||
metaKey: boolean,
|
||||
ctrlKey: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
key.toLowerCase() === 'b' && !altKey && controlOrMeta(metaKey, ctrlKey)
|
||||
);
|
||||
}
|
||||
|
||||
export function isItalic(
|
||||
key: string,
|
||||
altKey: boolean,
|
||||
metaKey: boolean,
|
||||
ctrlKey: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
key.toLowerCase() === 'i' && !altKey && controlOrMeta(metaKey, ctrlKey)
|
||||
);
|
||||
}
|
||||
|
||||
export function isUnderline(
|
||||
key: string,
|
||||
altKey: boolean,
|
||||
metaKey: boolean,
|
||||
ctrlKey: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
key.toLowerCase() === 'u' && !altKey && controlOrMeta(metaKey, ctrlKey)
|
||||
);
|
||||
}
|
||||
|
||||
export function isParagraph(key: string, shiftKey: boolean): boolean {
|
||||
return isReturn(key) && !shiftKey;
|
||||
}
|
||||
@@ -1371,6 +1338,10 @@ export function $isInlineElementOrDecoratorNode(node: LexicalNode): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function $isInlineElementOrTextNode(node: LexicalNode|null): boolean {
|
||||
return node !== null && ($isTextNode(node) || ($isElementNode(node) && node.isInline()));
|
||||
}
|
||||
|
||||
export function $getNearestRootOrShadowRoot(
|
||||
node: LexicalNode,
|
||||
): RootNode | ElementNode {
|
||||
|
||||
@@ -39,6 +39,8 @@ import {EditorUiContext} from "../../../../ui/framework/core";
|
||||
import {EditorUIManager} from "../../../../ui/framework/manager";
|
||||
import {ImageNode} from "@lexical/rich-text/LexicalImageNode";
|
||||
import {MediaNode} from "@lexical/rich-text/LexicalMediaNode";
|
||||
import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
|
||||
import {DiagramDecorator} from "../../../../ui/decorators/DiagramDecorator";
|
||||
|
||||
type TestEnv = {
|
||||
readonly container: HTMLDivElement;
|
||||
@@ -489,6 +491,7 @@ export function createTestContext(): EditorUiContext {
|
||||
nodes: [
|
||||
ImageNode,
|
||||
MediaNode,
|
||||
DiagramNode,
|
||||
]
|
||||
});
|
||||
|
||||
@@ -509,6 +512,7 @@ export function createTestContext(): EditorUiContext {
|
||||
};
|
||||
|
||||
context.manager.setContext(context);
|
||||
context.manager.registerDecoratorType('diagram', DiagramDecorator);
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -837,22 +841,31 @@ 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) {
|
||||
interface TestKeyboardEventOptions {
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
keyCode?: number;
|
||||
}
|
||||
|
||||
export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string, options: TestKeyboardEventOptions = {}) {
|
||||
const nodeDomEl = editor.getElementByKey(node.getKey());
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key,
|
||||
...options,
|
||||
});
|
||||
nodeDomEl?.dispatchEvent(event);
|
||||
editor.commitUpdates();
|
||||
}
|
||||
|
||||
export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
|
||||
export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string, options: TestKeyboardEventOptions = {}) {
|
||||
editor.getEditorState().read((): void => {
|
||||
const node = $getSelection()?.getNodes()[0] || null;
|
||||
if (node) {
|
||||
dispatchKeydownEventForNode(node, editor, key);
|
||||
dispatchKeydownEventForNode(node, editor, key, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@ import type {KlassConstructor, Spread} from 'lexical';
|
||||
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {$isTextNode, TextNode} from '../index';
|
||||
import {$isTextNode, TextNode} from './LexicalTextNode';
|
||||
import {
|
||||
DOUBLE_LINE_BREAK,
|
||||
|
||||
|
||||
} from '../LexicalConstants';
|
||||
import {LexicalNode} from '../LexicalNode';
|
||||
import {
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
import {errorOnReadOnly} from '../LexicalUpdates';
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
$getCompositionKey,
|
||||
$getCompositionKey, $isInlineElementOrTextNode,
|
||||
$setCompositionKey,
|
||||
getCachedClassNameArray,
|
||||
internalMarkSiblingsAsDirty,
|
||||
@@ -275,6 +275,16 @@ function wrapElementWith(
|
||||
return el;
|
||||
}
|
||||
|
||||
function alternatingWhitespaceReplacer(inlineAdjacent: boolean): (match: string) => string {
|
||||
return (match: string): string => {
|
||||
let offset = inlineAdjacent ? 1 : 0;
|
||||
return match
|
||||
.split('')
|
||||
.map((char, i) => (i % 2 === offset ? '\u00A0' : char))
|
||||
.join('');
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export interface TextNode {
|
||||
getTopLevelElement(): ElementNode | null;
|
||||
@@ -626,10 +636,15 @@ export class TextNode extends LexicalNode {
|
||||
'Expected TextNode createDOM to always return a HTMLElement',
|
||||
);
|
||||
|
||||
// Wrap up to retain space if head/tail whitespace exists
|
||||
const text = this.getTextContent();
|
||||
if (/^\s|\s$/.test(text)) {
|
||||
element.style.whiteSpace = 'pre-wrap';
|
||||
// Handle head/tail whitespace if it exists
|
||||
let text = this.getTextContent();
|
||||
const prevIsInline = $isInlineElementOrTextNode(this.getPreviousSibling());
|
||||
const nextIsInline = $isInlineElementOrTextNode(this.getNextSibling());
|
||||
if (/^\s/.test(text)) {
|
||||
text = text.replace(/^(\s+)/, alternatingWhitespaceReplacer(prevIsInline));
|
||||
}
|
||||
if (/\s$/.test(text)) {
|
||||
text = text.replace(/(\s+)$/, alternatingWhitespaceReplacer(nextIsInline));
|
||||
}
|
||||
|
||||
// Strip editor theme classes
|
||||
@@ -645,6 +660,14 @@ export class TextNode extends LexicalNode {
|
||||
// Remove placeholder tag if redundant
|
||||
if (element.nodeName === 'SPAN' && !element.getAttribute('style')) {
|
||||
element = document.createTextNode(text);
|
||||
} else {
|
||||
// Apply whitespace replaced text to the element
|
||||
// Search down the child chain in the event this element is already wrapped
|
||||
let child: Element = element;
|
||||
while (child.childElementCount > 0) {
|
||||
child = child.children[0];
|
||||
}
|
||||
child.textContent = text;
|
||||
}
|
||||
|
||||
// This is the only way to properly add support for most clients,
|
||||
@@ -653,7 +676,7 @@ export class TextNode extends LexicalNode {
|
||||
if (this.hasFormat('bold') && originalElementName !== 'strong') {
|
||||
element = wrapElementWith(element, 'strong');
|
||||
}
|
||||
if (this.hasFormat('italic')) {
|
||||
if (this.hasFormat('italic') && originalElementName !== 'em') {
|
||||
element = wrapElementWith(element, 'em');
|
||||
}
|
||||
if (this.hasFormat('strikethrough')) {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
getEditorStateTextContent,
|
||||
} from '../../../LexicalUtils';
|
||||
import {$generateHtmlFromNodes} from "@lexical/html";
|
||||
import {setEditorContentFromHtml} from "../../../../../utils/actions";
|
||||
|
||||
const editorConfig = Object.freeze({
|
||||
namespace: '',
|
||||
@@ -806,12 +807,13 @@ describe('LexicalTextNode tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('simple text wrapped in span if leading or ending spacing', async () => {
|
||||
test('non-breaking-spaces used if leading or ending spacing', async () => {
|
||||
|
||||
const textByExpectedHtml = {
|
||||
'hello ': '<p><span style="white-space: pre-wrap;">hello </span></p>',
|
||||
' hello': '<p><span style="white-space: pre-wrap;"> hello</span></p>',
|
||||
' hello ': '<p><span style="white-space: pre-wrap;"> hello </span></p>',
|
||||
'hello ': '<p>hello </p>',
|
||||
' hello': '<p> hello</p>',
|
||||
' hello ': '<p> hello </p>',
|
||||
'hello ': '<p>hello </p>',
|
||||
}
|
||||
|
||||
await update(() => {
|
||||
@@ -827,6 +829,31 @@ describe('LexicalTextNode tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('normal spaces used when text is adjacent to other inline text', async () => {
|
||||
await update(() => {
|
||||
setEditorContentFromHtml($getEditor(), '<p> Hello <strong>there</strong> is <em>text</em> here </p>');
|
||||
});
|
||||
|
||||
await update(() => {
|
||||
const html = $generateHtmlFromNodes($getEditor(), null);
|
||||
expect(html).toBe('<p> Hello <strong>there</strong> is <em>text</em> here </p>');
|
||||
});
|
||||
});
|
||||
|
||||
test('normal and non-breaking spaces used when text with multiple spaces is adjacent to inline text', async () => {
|
||||
await update(() => {
|
||||
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
|
||||
$getRoot().append(paragraph);
|
||||
paragraph.append($createTextNode('hello '));
|
||||
const bold = $createTextNode('world ');
|
||||
bold.setFormat("bold");
|
||||
paragraph.append(bold);
|
||||
|
||||
const html = $generateHtmlFromNodes($getEditor(), null);
|
||||
expect(html).toBe('<p>hello <strong>world </strong></p>');
|
||||
});
|
||||
});
|
||||
|
||||
test('text with formats exports using format elements instead of classes', async () => {
|
||||
await update(() => {
|
||||
const paragraph = $getRoot().getFirstChild<ElementNode>()!;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import type {ListNode, ListType} from './';
|
||||
import type {ListNode, ListType} from './LexicalListNode';
|
||||
import type {
|
||||
BaseSelection,
|
||||
DOMConversionMap,
|
||||
@@ -32,10 +32,11 @@ import {
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {$createListNode, $isListNode} from './';
|
||||
import {$createListNode, $isListNode} from './LexicalListNode';
|
||||
import {mergeLists} from './formatList';
|
||||
import {isNestedListNode} from './utils';
|
||||
import {el} from "../../utils/dom";
|
||||
import {$escapeListAtItem} from "../../utils/lists";
|
||||
|
||||
export type SerializedListItemNode = Spread<
|
||||
{
|
||||
@@ -273,21 +274,13 @@ export class ListItemNode extends ElementNode {
|
||||
restoreSelection = true,
|
||||
): ListItemNode | ParagraphNode | null {
|
||||
|
||||
if (this.getTextContent().trim() === '' && this.isLastChild()) {
|
||||
const list = this.getParentOrThrow<ListNode>();
|
||||
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();
|
||||
return paragraph;
|
||||
}
|
||||
// If we're adding a new empty item, and coming from an empty item,
|
||||
// take that as a desire to break from the current list level.
|
||||
// Intended to be a bit more lenient on the last list item hence ignores whitespace,
|
||||
// which allows empty items to be listed with just a space (except for last).
|
||||
const textContent = this.getTextContent();
|
||||
if (textContent === '' || (textContent.trim() === '' && this.isLastChild())) {
|
||||
return $escapeListAtItem(this);
|
||||
}
|
||||
|
||||
const newElement = $createListItemNode(
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
|
||||
|
||||
import {$createListItemNode, $isListItemNode, ListItemNode} from '.';
|
||||
import {$createListItemNode, $isListItemNode, ListItemNode} from './LexicalListItemNode';
|
||||
import {
|
||||
mergeNextSiblingListIfSameType,
|
||||
updateChildrenListItemValue,
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createRangeSelection,
|
||||
$getRoot, LexicalEditor,
|
||||
$getRoot, LexicalEditor, LexicalNode, ParagraphNode,
|
||||
TextNode,
|
||||
} from 'lexical';
|
||||
import {
|
||||
createTestContext, destroyFromContext,
|
||||
expectHtmlToBeEqual,
|
||||
expectHtmlToBeEqual, expectNodeShapeToMatch,
|
||||
html,
|
||||
} from 'lexical/__tests__/utils';
|
||||
|
||||
@@ -1200,6 +1200,70 @@ describe('LexicalListItemNode tests', () => {
|
||||
</ul>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('new items after empty top-level items splits the list in two, and inserts a paragraph inbetween', () => {
|
||||
let newItem: LexicalNode|null = null;
|
||||
const input = `<ul>
|
||||
<li>Item A</li>
|
||||
<li></li>
|
||||
<li>Item C</li>
|
||||
</ul>`;
|
||||
|
||||
editor.updateAndCommit(() => {
|
||||
const root = $getRoot();
|
||||
root.append(...$htmlToBlockNodes(editor, input));
|
||||
const list = root.getFirstChild() as ListNode;
|
||||
const itemB = list.getChildAtIndex(1) as ListItemNode;
|
||||
|
||||
newItem = itemB.insertNewAfter($createRangeSelection());
|
||||
});
|
||||
|
||||
expect(newItem).toBeInstanceOf(ParagraphNode);
|
||||
|
||||
expectNodeShapeToMatch(editor, [
|
||||
{
|
||||
type: 'list',
|
||||
children: [{type: 'listitem', children: [{text: 'Item A'}]}],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
children: [{type: 'listitem', children: [{text: 'Item C'}]}],
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
test('new items after last empty top-level inserts a new paragraph below, and removes the list item', () => {
|
||||
let newItem: LexicalNode|null = null;
|
||||
const input = `<ul>
|
||||
<li>Item A</li>
|
||||
<li>Item B</li>
|
||||
<li></li>
|
||||
</ul>`;
|
||||
|
||||
editor.updateAndCommit(() => {
|
||||
const root = $getRoot();
|
||||
root.append(...$htmlToBlockNodes(editor, input));
|
||||
const list = root.getFirstChild() as ListNode;
|
||||
const itemC = list.getChildAtIndex(2) as ListItemNode;
|
||||
|
||||
newItem = itemC.insertNewAfter($createRangeSelection());
|
||||
});
|
||||
|
||||
expect(newItem).toBeInstanceOf(ParagraphNode);
|
||||
|
||||
expectNodeShapeToMatch(editor, [
|
||||
{
|
||||
type: 'list',
|
||||
children: [{type: 'listitem', children: [{text: 'Item A'}]}, {type: 'listitem', children: [{text: 'Item B'}]}],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
},
|
||||
])
|
||||
});
|
||||
});
|
||||
|
||||
test('$createListItemNode()', async () => {
|
||||
|
||||
@@ -25,12 +25,14 @@ import invariant from 'lexical/shared/invariant';
|
||||
|
||||
import {
|
||||
$createListItemNode,
|
||||
$createListNode,
|
||||
$isListItemNode,
|
||||
$isListNode,
|
||||
ListItemNode,
|
||||
} from './LexicalListItemNode';
|
||||
import {
|
||||
$createListNode,
|
||||
$isListNode,
|
||||
ListNode,
|
||||
} from './';
|
||||
} from './LexicalListNode';
|
||||
import {ListType} from './LexicalListNode';
|
||||
import {
|
||||
$getAllListItems,
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
|
||||
import type {SerializedListItemNode} from './LexicalListItemNode';
|
||||
import type {ListType, SerializedListNode} from './LexicalListNode';
|
||||
import type {LexicalCommand} from 'lexical';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection, COMMAND_PRIORITY_NORMAL, INSERT_PARAGRAPH_COMMAND, LexicalCommand, LexicalEditor
|
||||
} from 'lexical';
|
||||
|
||||
import {createCommand} from 'lexical';
|
||||
|
||||
@@ -20,6 +23,8 @@ import {
|
||||
} from './LexicalListItemNode';
|
||||
import {$createListNode, $isListNode, ListNode} from './LexicalListNode';
|
||||
import {$getListDepth} from './utils';
|
||||
import {mergeRegister} from "@lexical/utils";
|
||||
import {$getAncestor, INTERNAL_$isBlock} from "lexical/LexicalUtils";
|
||||
|
||||
export {
|
||||
$createListItemNode,
|
||||
@@ -48,3 +53,34 @@ export const INSERT_CHECK_LIST_COMMAND: LexicalCommand<void> = createCommand(
|
||||
export const REMOVE_LIST_COMMAND: LexicalCommand<void> = createCommand(
|
||||
'REMOVE_LIST_COMMAND',
|
||||
);
|
||||
|
||||
export function registerLists(editor: LexicalEditor): () => void {
|
||||
return mergeRegister(
|
||||
|
||||
// Override the default insert paragraph command when within a list item
|
||||
// so that new blocks are inserted as their own list items.
|
||||
editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => {
|
||||
const selection = $getSelection();
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const block = $getAncestor(anchorNode, INTERNAL_$isBlock)!;
|
||||
const blockParent = block.getParent();
|
||||
if ($isListItemNode(blockParent)) {
|
||||
const newBlock = selection.insertParagraph();
|
||||
if (newBlock) {
|
||||
const newListItem = $createListItemNode();
|
||||
const newBlockSiblings = newBlock.getNextSiblings();
|
||||
newListItem.append(newBlock, ...newBlockSiblings);
|
||||
blockParent.insertAfter(newListItem, false);
|
||||
newListItem.selectStart();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, COMMAND_PRIORITY_NORMAL),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ import invariant from 'lexical/shared/invariant';
|
||||
import {
|
||||
$createListItemNode,
|
||||
$isListItemNode,
|
||||
$isListNode,
|
||||
ListItemNode,
|
||||
} from './LexicalListItemNode';
|
||||
import {
|
||||
$isListNode,
|
||||
ListNode,
|
||||
} from './';
|
||||
} from './LexicalListNode';
|
||||
|
||||
/**
|
||||
* Checks the depth of listNode from the root node.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'lexical';
|
||||
|
||||
import {extractDirectionFromElement} from "lexical/nodes/common";
|
||||
import {$showDetailsForm} from "../../ui/defaults/forms/objects";
|
||||
|
||||
export type SerializedDetailsNode = Spread<{
|
||||
id: string;
|
||||
@@ -90,6 +91,16 @@ export class DetailsNode extends ElementNode {
|
||||
});
|
||||
});
|
||||
|
||||
summary.addEventListener('dblclick', event => {
|
||||
event.preventDefault();
|
||||
const uiContext = _editor.getUiContext();
|
||||
if (uiContext) {
|
||||
_editor.read(() => {
|
||||
$showDetailsForm(this, uiContext);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
el.append(summary);
|
||||
|
||||
return el;
|
||||
|
||||
@@ -27,7 +27,7 @@ export class DiagramNode extends DecoratorNode<EditorDecoratorAdapter> {
|
||||
}
|
||||
|
||||
static clone(node: DiagramNode): DiagramNode {
|
||||
const newNode = new DiagramNode(node.__drawingId, node.__drawingUrl);
|
||||
const newNode = new DiagramNode(node.__drawingId, node.__drawingUrl, node.__key);
|
||||
newNode.__id = node.__id;
|
||||
return newNode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {createTestContext} from "lexical/__tests__/utils";
|
||||
import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
|
||||
import {$getHtmlContent} from "@lexical/clipboard";
|
||||
import {getEditorContentAsHtml} from "../../../../utils/actions";
|
||||
import {$getRoot} from "lexical";
|
||||
|
||||
|
||||
describe('LexicalDiagramNode', () => {
|
||||
|
||||
test('clone creates new instance with same key', () => {
|
||||
const {editor} = createTestContext();
|
||||
editor.updateAndCommit(() => {
|
||||
const node = $createDiagramNode('10', 'https://example.com/barry.png');
|
||||
const clone = DiagramNode.clone(node);
|
||||
|
||||
expect(node).not.toBe(clone);
|
||||
expect(node.getKey()).toBe(clone.getKey());
|
||||
});
|
||||
});
|
||||
|
||||
test('output HTML format', async () => {
|
||||
const {editor} = createTestContext();
|
||||
editor.updateAndCommit(() => {
|
||||
const node = $createDiagramNode('10', 'https://example.com/barry.png');
|
||||
node.setId('cat-123');
|
||||
$getRoot().append(node);
|
||||
});
|
||||
|
||||
const html = await getEditorContentAsHtml(editor);
|
||||
expect(html).toBe(`<div id="cat-123" drawio-diagram="10"><img src="https://example.com/barry.png"></div>`);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
$createTextNode,
|
||||
DOMConversionMap,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
|
||||
@@ -334,10 +334,13 @@ export function $convertTableCellNodeElement(
|
||||
width = parseFloat(domNode_.style.width);
|
||||
}
|
||||
|
||||
let isHeader = nodeName === 'th';
|
||||
if (domNode instanceof HTMLElement && domNode.closest('thead')) {
|
||||
isHeader = true;
|
||||
}
|
||||
|
||||
const tableCellNode = $createTableCellNode(
|
||||
nodeName === 'th'
|
||||
? TableCellHeaderStates.ROW
|
||||
: TableCellHeaderStates.NO_STATUS,
|
||||
isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS,
|
||||
domNode_.colSpan,
|
||||
width,
|
||||
);
|
||||
|
||||
@@ -72,6 +72,7 @@ import {$isTableRowNode} from './LexicalTableRowNode';
|
||||
import {$isTableSelection} from './LexicalTableSelection';
|
||||
import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
|
||||
import {$selectOrCreateAdjacent} from "../../utils/nodes";
|
||||
import {$selectNodeAtXPixelOffset} from "../../utils/selection";
|
||||
|
||||
const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
|
||||
|
||||
@@ -1073,6 +1074,7 @@ const selectTableNodeInDirection = (
|
||||
x: number,
|
||||
y: number,
|
||||
direction: Direction,
|
||||
selectionOffset: number = -1
|
||||
): boolean => {
|
||||
const isForward = direction === 'forward';
|
||||
|
||||
@@ -1112,6 +1114,7 @@ const selectTableNodeInDirection = (
|
||||
selectTableCellNode(
|
||||
tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
|
||||
false,
|
||||
selectionOffset,
|
||||
);
|
||||
} else {
|
||||
$selectOrCreateAdjacent(tableNode, false);
|
||||
@@ -1124,6 +1127,7 @@ const selectTableNodeInDirection = (
|
||||
selectTableCellNode(
|
||||
tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
|
||||
true,
|
||||
selectionOffset,
|
||||
);
|
||||
} else {
|
||||
$selectOrCreateAdjacent(tableNode, true);
|
||||
@@ -1197,7 +1201,14 @@ function $isSelectionInTable(
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
|
||||
function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean, selectionOffsetPixels : number = -1) {
|
||||
if (selectionOffsetPixels !== -1) {
|
||||
const selection = $selectNodeAtXPixelOffset(tableCell, selectionOffsetPixels, fromStart);
|
||||
if (selection) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fromStart) {
|
||||
tableCell.selectStart();
|
||||
} else {
|
||||
@@ -1491,12 +1502,14 @@ function $handleArrowKey(
|
||||
tableObserver.setAnchorCellForSelection(cell);
|
||||
tableObserver.setFocusCellForSelection(cell, true);
|
||||
} else {
|
||||
const selectionOffset = edgeSelectionRect.x - edgeRect.x;
|
||||
return selectTableNodeInDirection(
|
||||
tableObserver,
|
||||
tableNode,
|
||||
cords.x,
|
||||
cords.y,
|
||||
direction,
|
||||
selectionOffset
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
91
resources/js/wysiwyg/services/__tests__/shortcuts.test.ts
Normal file
91
resources/js/wysiwyg/services/__tests__/shortcuts.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
createTestContext, destroyFromContext,
|
||||
dispatchKeydownEventForSelectedNode, expectEditorStateJSONPropToEqual,
|
||||
} from "lexical/__tests__/utils";
|
||||
import {
|
||||
$createParagraphNode, $createTextNode,
|
||||
$getRoot, IS_BOLD, LexicalEditor,
|
||||
} from "lexical";
|
||||
import {registerRichText} from "@lexical/rich-text";
|
||||
import {EditorUiContext} from "../../ui/framework/core";
|
||||
import {registerShortcuts} from "../shortcuts";
|
||||
|
||||
describe('Keyboard-handling service tests', () => {
|
||||
|
||||
let context!: EditorUiContext;
|
||||
let editor!: LexicalEditor;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
editor = context.editor;
|
||||
registerRichText(editor);
|
||||
registerShortcuts(context, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyFromContext(context);
|
||||
});
|
||||
|
||||
test('Basic block format shortcuts works', () => {
|
||||
editor.updateAndCommit(() => {
|
||||
const p = $createParagraphNode();
|
||||
p.append($createTextNode('Hello World'))
|
||||
$getRoot().append(p);
|
||||
p.select();
|
||||
});
|
||||
|
||||
dispatchKeydownEventForSelectedNode(editor, '1', {ctrlKey: true});
|
||||
|
||||
expectEditorStateJSONPropToEqual(editor, '0.type', 'heading');
|
||||
expectEditorStateJSONPropToEqual(editor, '0.tag', 'h2');
|
||||
|
||||
dispatchKeydownEventForSelectedNode(editor, '2', {ctrlKey: true});
|
||||
|
||||
expectEditorStateJSONPropToEqual(editor, '0.type', 'heading');
|
||||
expectEditorStateJSONPropToEqual(editor, '0.tag', 'h3');
|
||||
|
||||
dispatchKeydownEventForSelectedNode(editor, 'd', {ctrlKey: true});
|
||||
|
||||
expectEditorStateJSONPropToEqual(editor, '0.type', 'paragraph');
|
||||
expectEditorStateJSONPropToEqual(editor, '0.0.text', 'Hello World');
|
||||
});
|
||||
|
||||
test('Basic bold format shortcut works', () => {
|
||||
editor.updateAndCommit(() => {
|
||||
const p = $createParagraphNode();
|
||||
const text = $createTextNode('Hello World');
|
||||
p.append(text)
|
||||
$getRoot().append(p);
|
||||
text.select(0, 5);
|
||||
});
|
||||
|
||||
// Toggle bold for selection
|
||||
dispatchKeydownEventForSelectedNode(editor, 'b', {ctrlKey: true});
|
||||
expectEditorStateJSONPropToEqual(editor, '0.0.format', IS_BOLD);
|
||||
expectEditorStateJSONPropToEqual(editor, '0.1.format', 0);
|
||||
|
||||
// Untoggle bold for selection
|
||||
dispatchKeydownEventForSelectedNode(editor, 'b', {ctrlKey: true});
|
||||
expectEditorStateJSONPropToEqual(editor, '0.0.format', 0);
|
||||
});
|
||||
|
||||
test('Basic bold format shortcut works when using cyrillic equivalent keys', () => {
|
||||
editor.updateAndCommit(() => {
|
||||
const p = $createParagraphNode();
|
||||
const text = $createTextNode('Hello World');
|
||||
p.append(text)
|
||||
$getRoot().append(p);
|
||||
text.select(0, 5);
|
||||
});
|
||||
|
||||
// Toggle bold for selection
|
||||
dispatchKeydownEventForSelectedNode(editor, 'и', {ctrlKey: true, keyCode: 66});
|
||||
expectEditorStateJSONPropToEqual(editor, '0.0.format', IS_BOLD);
|
||||
expectEditorStateJSONPropToEqual(editor, '0.1.format', 0);
|
||||
|
||||
// Untoggle bold for selection
|
||||
dispatchKeydownEventForSelectedNode(editor, 'и', {ctrlKey: true, keyCode: 66});
|
||||
expectEditorStateJSONPropToEqual(editor, '0.0.format', 0);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,40 +1,38 @@
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createParagraphNode, $getSelection,
|
||||
$insertNodes,
|
||||
$isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND,
|
||||
$isDecoratorNode,
|
||||
$isRangeSelection, $isTextNode, $setSelection, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND,
|
||||
LexicalEditor,
|
||||
LexicalNode, PASTE_COMMAND
|
||||
} from "lexical";
|
||||
import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection";
|
||||
import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes";
|
||||
import {$getBlockElementNodesInSelection, $insertNewNodesAtSelection, $selectSingleNode} from "../utils/selection";
|
||||
import {$getNodePositionFromMouseEvent, $htmlToBlockNodes, $htmlToNodes} from "../utils/nodes";
|
||||
import {Clipboard} from "../../services/clipboard";
|
||||
import {$createImageNode} from "@lexical/rich-text/LexicalImageNode";
|
||||
import {$createLinkNode} from "@lexical/link";
|
||||
import {EditorImageData, uploadImageFile} from "../utils/images";
|
||||
import {EditorUiContext} from "../ui/framework/core";
|
||||
import {$getHtmlContent} from "@lexical/clipboard";
|
||||
|
||||
function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null {
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
const dom = document.elementFromPoint(x, y);
|
||||
if (!dom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $getNearestBlockNodeForCoords(editor, event.clientX, event.clientY);
|
||||
}
|
||||
const internalActiveDragTracker: WeakMap<LexicalEditor, DragEvent> = new WeakMap();
|
||||
|
||||
function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) {
|
||||
const positionNode = $getNodeFromMouseEvent(event, editor);
|
||||
const position = $getNodePositionFromMouseEvent(event, editor);
|
||||
|
||||
if (positionNode) {
|
||||
$selectSingleNode(positionNode);
|
||||
if (position && $isTextNode(position.node)) {
|
||||
const selection = position.node.select(position.offset, position.offset);
|
||||
$setSelection(selection);
|
||||
} else if (position) {
|
||||
$selectSingleNode(position.node);
|
||||
}
|
||||
|
||||
$insertNewBlockNodesAtSelection(nodes, true);
|
||||
$insertNewNodesAtSelection(nodes);
|
||||
|
||||
if (!$isDecoratorNode(positionNode) || !positionNode?.getTextContent()) {
|
||||
positionNode?.remove();
|
||||
if (position) {
|
||||
if (!$isDecoratorNode(position.node) && !position.node?.getTextContent()) {
|
||||
position.node.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +47,26 @@ async function insertTemplateToEditor(editor: LexicalEditor, templateId: string,
|
||||
});
|
||||
}
|
||||
|
||||
function insertHtmlToEditor(editor: LexicalEditor, html: string, isFromInternal: boolean, event: DragEvent) {
|
||||
editor.update(() => {
|
||||
if (isFromInternal) {
|
||||
const selected = $getSelection();
|
||||
if ($isRangeSelection(selected)) {
|
||||
selected.removeText();
|
||||
const selectionBlocks = $getBlockElementNodesInSelection(selected);
|
||||
for (const block of selectionBlocks) {
|
||||
if (block.isEmpty()) {
|
||||
block.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newNodes = $htmlToNodes(editor, html);
|
||||
$insertNodesAtEvent(newNodes, event, editor);
|
||||
});
|
||||
}
|
||||
|
||||
function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolean {
|
||||
const clipboard = new Clipboard(data);
|
||||
let handled = false;
|
||||
@@ -113,6 +131,10 @@ function handleImageLinkInsert(data: DataTransfer, context: EditorUiContext): bo
|
||||
function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean {
|
||||
const editor = context.editor;
|
||||
return (event: DragEvent): boolean => {
|
||||
|
||||
const hadInternalActiveDrag = internalActiveDragTracker.has(editor);
|
||||
internalActiveDragTracker.delete(editor);
|
||||
|
||||
// Template handling
|
||||
const templateId = event.dataTransfer?.getData('bookstack/template') || '';
|
||||
if (templateId) {
|
||||
@@ -125,10 +147,7 @@ function createDropListener(context: EditorUiContext): (event: DragEvent) => boo
|
||||
// HTML contents drop
|
||||
const html = event.dataTransfer?.getData('text/html') || '';
|
||||
if (html) {
|
||||
editor.update(() => {
|
||||
const newNodes = $htmlToBlockNodes(editor, html);
|
||||
$insertNodesAtEvent(newNodes, event, editor);
|
||||
});
|
||||
insertHtmlToEditor(editor, html, hadInternalActiveDrag, event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
@@ -165,17 +184,39 @@ function createPasteListener(context: EditorUiContext): (event: ClipboardEvent)
|
||||
};
|
||||
}
|
||||
|
||||
function createDragStartListener(context: EditorUiContext): (event: DragEvent) => boolean {
|
||||
return (event: DragEvent) => {
|
||||
// Track when drag events are started internally from the editor
|
||||
internalActiveDragTracker.set(context.editor, event);
|
||||
|
||||
// If an internal range selection, serialize the range contents
|
||||
// fully as output HTML, instead of editor HTML
|
||||
context.editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.extract();
|
||||
const html = $getHtmlContent(context.editor, selection);
|
||||
event.dataTransfer?.setData('text/html', html);
|
||||
}
|
||||
});
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
export function registerDropPasteHandling(context: EditorUiContext): () => void {
|
||||
const dropListener = createDropListener(context);
|
||||
const pasteListener = createPasteListener(context);
|
||||
const dragstartListener = createDragStartListener(context);
|
||||
|
||||
const unregisterDrop = context.editor.registerCommand(DROP_COMMAND, dropListener, COMMAND_PRIORITY_HIGH);
|
||||
const unregisterPaste = context.editor.registerCommand(PASTE_COMMAND, pasteListener, COMMAND_PRIORITY_HIGH);
|
||||
const unregisterDragStart = context.editor.registerCommand(DRAGSTART_COMMAND, dragstartListener, COMMAND_PRIORITY_HIGH);
|
||||
context.scrollDOM.addEventListener('drop', dropListener);
|
||||
|
||||
return () => {
|
||||
unregisterDrop();
|
||||
unregisterPaste();
|
||||
unregisterDragStart();
|
||||
context.scrollDOM.removeEventListener('drop', dropListener);
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import {$getSelection, COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical";
|
||||
import {
|
||||
$getSelection,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
KEY_ENTER_COMMAND,
|
||||
LexicalEditor,
|
||||
TextFormatType
|
||||
} from "lexical";
|
||||
import {
|
||||
cycleSelectionCalloutFormats,
|
||||
formatCodeBlock, insertOrUpdateLink,
|
||||
@@ -27,8 +34,8 @@ function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): Shortcu
|
||||
};
|
||||
}
|
||||
|
||||
function toggleInlineCode(editor: LexicalEditor): boolean {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
|
||||
function toggleInlineFormat(editor: LexicalEditor, format: TextFormatType): boolean {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -38,29 +45,12 @@ type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boole
|
||||
* List of action functions by their shortcut combo.
|
||||
* We use "meta" as an abstraction for ctrl/cmd depending on platform.
|
||||
*/
|
||||
const actionsByKeys: Record<string, ShortcutAction> = {
|
||||
'meta+s': () => {
|
||||
window.$events.emit('editor-save-draft');
|
||||
return true;
|
||||
},
|
||||
'meta+enter': () => {
|
||||
window.$events.emit('editor-save-page');
|
||||
return true;
|
||||
},
|
||||
'meta+1': (editor, context) => headerHandler(context, 'h2'),
|
||||
'meta+2': (editor, context) => headerHandler(context, 'h3'),
|
||||
'meta+3': (editor, context) => headerHandler(context, 'h4'),
|
||||
'meta+4': (editor, context) => headerHandler(context, 'h5'),
|
||||
'meta+5': wrapFormatAction(toggleSelectionAsParagraph),
|
||||
'meta+d': wrapFormatAction(toggleSelectionAsParagraph),
|
||||
'meta+6': wrapFormatAction(toggleSelectionAsBlockquote),
|
||||
'meta+q': wrapFormatAction(toggleSelectionAsBlockquote),
|
||||
'meta+7': wrapFormatAction(formatCodeBlock),
|
||||
'meta+e': wrapFormatAction(formatCodeBlock),
|
||||
'meta+8': toggleInlineCode,
|
||||
'meta+shift+e': toggleInlineCode,
|
||||
'meta+9': wrapFormatAction(cycleSelectionCalloutFormats),
|
||||
|
||||
const baseActionsByKeys: Record<string, ShortcutAction> = {
|
||||
'meta+8': (e) => toggleInlineFormat(e, 'code'),
|
||||
'meta+shift+e': (e) => toggleInlineFormat(e, 'code'),
|
||||
'meta+b': (e) => toggleInlineFormat(e, 'bold'),
|
||||
'meta+i': (e) => toggleInlineFormat(e, 'italic'),
|
||||
'meta+u': (e) => toggleInlineFormat(e, 'underline'),
|
||||
'meta+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')),
|
||||
'meta+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')),
|
||||
'meta+k': (editor, context) => {
|
||||
@@ -87,30 +77,105 @@ const actionsByKeys: Record<string, ShortcutAction> = {
|
||||
},
|
||||
};
|
||||
|
||||
function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => void {
|
||||
/**
|
||||
* An extended set of the above, used for fuller-featured editors with heavier block-level formatting.
|
||||
*/
|
||||
const extendedActionsByKeys: Record<string, ShortcutAction> = {
|
||||
...baseActionsByKeys,
|
||||
'meta+s': () => {
|
||||
window.$events.emit('editor-save-draft');
|
||||
return true;
|
||||
},
|
||||
'meta+enter': () => {
|
||||
window.$events.emit('editor-save-page');
|
||||
return true;
|
||||
},
|
||||
'meta+1': (editor, context) => headerHandler(context, 'h2'),
|
||||
'meta+2': (editor, context) => headerHandler(context, 'h3'),
|
||||
'meta+3': (editor, context) => headerHandler(context, 'h4'),
|
||||
'meta+4': (editor, context) => headerHandler(context, 'h5'),
|
||||
'meta+5': wrapFormatAction(toggleSelectionAsParagraph),
|
||||
'meta+d': wrapFormatAction(toggleSelectionAsParagraph),
|
||||
'meta+6': wrapFormatAction(toggleSelectionAsBlockquote),
|
||||
'meta+7': wrapFormatAction(formatCodeBlock),
|
||||
'meta+e': wrapFormatAction(formatCodeBlock),
|
||||
'meta+q': wrapFormatAction(toggleSelectionAsBlockquote),
|
||||
'meta+9': wrapFormatAction(cycleSelectionCalloutFormats),
|
||||
};
|
||||
|
||||
function createKeyDownListener(context: EditorUiContext, useExtended: boolean): (e: KeyboardEvent) => void {
|
||||
const baseKeySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys;
|
||||
const keySetToUse = extendKeySetWithKeyCodes(baseKeySetToUse);
|
||||
return (event: KeyboardEvent) => {
|
||||
const combo = keyboardEventToKeyComboString(event);
|
||||
// console.log(`pressed: ${combo}`);
|
||||
if (actionsByKeys[combo]) {
|
||||
const handled = actionsByKeys[combo](context.editor, context);
|
||||
if (handled) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const comboStrings = keyboardEventToKeyComboStrings(event);
|
||||
// console.log(comboStrings, event, keySetToUse);
|
||||
for (const combo of comboStrings) {
|
||||
if (keySetToUse[combo]) {
|
||||
const handled = keySetToUse[combo](context.editor, context);
|
||||
if (handled) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function keyboardEventToKeyComboString(event: KeyboardEvent): string {
|
||||
/**
|
||||
* Takes a shortcut key set and returns a new set with added variations of shortcts where
|
||||
* they can be sensibly represented as their key code instead of just key, which we can use
|
||||
* for matching in scenarios where the physical key may be represented of the letter used
|
||||
* in the shortcut, but produces a different 'key' value.
|
||||
* Useful for Cyrillic scenarios where the keyboard key would show a latin character
|
||||
* as an option, and therefore be expected for use for the relevant latin shortcut, but the main
|
||||
* key output is a Cyrillic character.
|
||||
*/
|
||||
function extendKeySetWithKeyCodes(keySet: Record<string, ShortcutAction>): Record<string, ShortcutAction> {
|
||||
const newKeys: Record<string, ShortcutAction> = {};
|
||||
|
||||
const setKeys = Object.keys(keySet);
|
||||
for (const keyCombo of setKeys) {
|
||||
const action = keySet[keyCombo];
|
||||
newKeys[keyCombo] = action;
|
||||
|
||||
const comboParts = keyCombo.split('+');
|
||||
const lastComboPart = comboParts.pop() || '';
|
||||
if (lastComboPart.match(/^[a-zA-Z]$/)) {
|
||||
const keyCode = lastComboPart.toUpperCase().charCodeAt(0);
|
||||
comboParts.push(String(keyCode));
|
||||
const newCombo = comboParts.join('+');
|
||||
newKeys[newCombo] = action;
|
||||
}
|
||||
}
|
||||
|
||||
return newKeys;
|
||||
}
|
||||
|
||||
function keyboardEventToKeyComboStrings(event: KeyboardEvent): string[] {
|
||||
const metaKeyPressed = isMac() ? event.metaKey : event.ctrlKey;
|
||||
|
||||
const parts = [
|
||||
const mainParts = [
|
||||
metaKeyPressed ? 'meta' : '',
|
||||
event.shiftKey ? 'shift' : '',
|
||||
event.key,
|
||||
];
|
||||
|
||||
return parts.filter(Boolean).join('+').toLowerCase();
|
||||
const toReturn = [
|
||||
mainParts.filter(Boolean).join('+').toLowerCase(),
|
||||
];
|
||||
|
||||
// If ending with a standard latin character, provide an alternative
|
||||
// keyCode based option for scenarios of dual-language keyboard use.
|
||||
const keyCode = event.keyCode || 0;
|
||||
if (keyCode >= 65 && keyCode <= 90) {
|
||||
const keyCodeParts = [...mainParts];
|
||||
keyCodeParts.pop();
|
||||
keyCodeParts.push(String(keyCode));
|
||||
toReturn.push(keyCodeParts.filter(Boolean).join('+').toLowerCase());
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
function isMac(): boolean {
|
||||
@@ -127,8 +192,8 @@ function overrideDefaultCommands(editor: LexicalEditor) {
|
||||
}, COMMAND_PRIORITY_HIGH);
|
||||
}
|
||||
|
||||
export function registerShortcuts(context: EditorUiContext) {
|
||||
const listener = createKeyDownListener(context);
|
||||
export function registerShortcuts(context: EditorUiContext, useExtended: boolean) {
|
||||
const listener = createKeyDownListener(context, useExtended);
|
||||
overrideDefaultCommands(context.editor);
|
||||
|
||||
return context.editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {EditorDecorator} from "../framework/decorator";
|
||||
import {EditorUiContext} from "../framework/core";
|
||||
import {BaseSelection, CLICK_COMMAND, COMMAND_PRIORITY_NORMAL} from "lexical";
|
||||
import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode";
|
||||
import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection";
|
||||
|
||||
@@ -221,7 +221,7 @@ export const detailsEditLabel: EditorButtonDefinition = {
|
||||
if ($isDetailsNode(details)) {
|
||||
$showDetailsForm(details, context);
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
isActive(selection: BaseSelection | null): boolean {
|
||||
return false;
|
||||
|
||||
@@ -7,6 +7,7 @@ import insertColumnAfterIcon from "@icons/editor/table-insert-column-after.svg";
|
||||
import insertColumnBeforeIcon from "@icons/editor/table-insert-column-before.svg";
|
||||
import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg";
|
||||
import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg";
|
||||
import tableHeaderIcon from "@icons/editor/table-header.svg";
|
||||
import {EditorUiContext} from "../../framework/core";
|
||||
import {$getSelection, BaseSelection} from "lexical";
|
||||
import {
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
$deleteTableRow__EXPERIMENTAL,
|
||||
$insertTableColumn__EXPERIMENTAL,
|
||||
$insertTableRow__EXPERIMENTAL, $isTableCellNode,
|
||||
$isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode,
|
||||
$isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellHeaderStates, TableCellNode,
|
||||
} from "@lexical/table";
|
||||
import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection";
|
||||
import {$getParentOfType} from "../../../utils/nodes";
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
$clearTableFormatting,
|
||||
$clearTableSizes, $getTableFromSelection,
|
||||
$getTableRowsFromSelection,
|
||||
$mergeTableCellsInSelection
|
||||
$mergeTableCellsInSelection, $toggleRowCellHeaderState
|
||||
} from "../../../utils/tables";
|
||||
import {
|
||||
$copySelectedColumnsToClipboard,
|
||||
@@ -239,6 +240,28 @@ export const pasteRowAfter: EditorButtonDefinition = {
|
||||
isDisabled: (selection) => cellNotSelected(selection) || isRowClipboardEmpty(),
|
||||
};
|
||||
|
||||
export const toggleRowHeaders: EditorButtonDefinition = {
|
||||
label: 'Row header',
|
||||
format: 'small',
|
||||
icon: tableHeaderIcon,
|
||||
action(context: EditorUiContext, button) {
|
||||
context.editor.update(() => {
|
||||
const row = $getNodeFromSelection($getSelection(), $isTableCellNode)?.getParent();
|
||||
if (!$isTableRowNode(row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNowHeader = $toggleRowCellHeaderState(row);
|
||||
button.setActiveState(isNowHeader);
|
||||
});
|
||||
},
|
||||
isActive: (selection) => {
|
||||
return $selectionContainsNodeType(selection, (node) => {
|
||||
return $isTableCellNode(node) && node.getHeaderStyles() !== TableCellHeaderStates.NO_STATUS;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const cutColumn: EditorButtonDefinition = {
|
||||
label: 'Cut column',
|
||||
format: 'long',
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {EditorFormDefinition} from "../../framework/forms";
|
||||
import {EditorUiContext, EditorUiElement} from "../../framework/core";
|
||||
import {setEditorContentFromHtml} from "../../../utils/actions";
|
||||
import {focusEditor, setEditorContentFromHtml} from "../../../utils/actions";
|
||||
import {ExternalContent} from "../../framework/blocks/external-content";
|
||||
|
||||
export const source: EditorFormDefinition = {
|
||||
submitText: 'Save',
|
||||
async action(formData, context: EditorUiContext) {
|
||||
setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || '');
|
||||
context.editor.commitUpdates();
|
||||
window.requestAnimationFrame(() => {
|
||||
focusEditor(context.editor);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
fields: [
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
pasteRowBefore, resizeTableToContents,
|
||||
rowProperties,
|
||||
splitCell,
|
||||
table, tableProperties
|
||||
table, tableProperties, toggleRowHeaders
|
||||
} from "./buttons/tables";
|
||||
import {about, fullscreen, redo, source, undo} from "./buttons/controls";
|
||||
import {
|
||||
@@ -227,6 +227,7 @@ export function getBasicEditorToolbar(context: EditorUiContext): EditorContainer
|
||||
new EditorButton(bold),
|
||||
new EditorButton(italic),
|
||||
new EditorButton(link),
|
||||
new EditorButton(code),
|
||||
new EditorButton(bulletList),
|
||||
new EditorButton(numberList),
|
||||
])
|
||||
@@ -283,6 +284,19 @@ export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
|
||||
return originalTarget.closest('table') as HTMLTableElement;
|
||||
}
|
||||
},
|
||||
table_header: {
|
||||
selector: 'table tr:first-of-type td, table tr:first-of-type th',
|
||||
content() {
|
||||
return [
|
||||
new EditorOverflowContainer('table_headers', 1, [
|
||||
new EditorButton(toggleRowHeaders),
|
||||
]),
|
||||
];
|
||||
},
|
||||
displayTargetLocator(originalTarget: HTMLElement): HTMLElement {
|
||||
return originalTarget.closest('table') as HTMLTableElement;
|
||||
}
|
||||
},
|
||||
details: {
|
||||
selector: 'details',
|
||||
content() {
|
||||
|
||||
@@ -27,7 +27,7 @@ export class EditorTableCreator extends EditorUiElement {
|
||||
}, rowCells));
|
||||
}
|
||||
|
||||
const display = el('div', {class: 'editor-table-creator-display'}, ['0 x 0']);
|
||||
const display = el('div', {class: 'editor-table-creator-display', dir: 'ltr'}, ['0 x 0']);
|
||||
const grid = el('div', {class: 'editor-table-creator-grid'}, rows);
|
||||
grid.addEventListener('mousemove', event => {
|
||||
const cell = (event.target as HTMLElement).closest('.editor-table-creator-cell') as HTMLElement|null;
|
||||
|
||||
@@ -30,7 +30,7 @@ export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDef
|
||||
export abstract class EditorUiElement {
|
||||
protected dom: HTMLElement|null = null;
|
||||
private context: EditorUiContext|null = null;
|
||||
private abortController: AbortController = new AbortController();
|
||||
protected abortController: AbortController = new AbortController();
|
||||
|
||||
protected abstract buildDOM(): HTMLElement;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {getViewportRect, RTLRect} from "../../../utils/rtl";
|
||||
|
||||
interface HandleDropdownParams {
|
||||
toggle: HTMLElement;
|
||||
menu: HTMLElement;
|
||||
@@ -7,30 +9,31 @@ interface HandleDropdownParams {
|
||||
showAside?: boolean;
|
||||
}
|
||||
|
||||
function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean) {
|
||||
const toggleRect = toggle.getBoundingClientRect();
|
||||
const menuBounds = menu.getBoundingClientRect();
|
||||
function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean, isRTL: boolean) {
|
||||
const toggleRect = new RTLRect(toggle, isRTL);
|
||||
const menuBounds = new RTLRect(menu, isRTL);
|
||||
const viewport = getViewportRect();
|
||||
|
||||
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);
|
||||
let targetLeft = toggleRect.inlineEnd;
|
||||
const isEndOOB = toggleRect.inlineEnd + menuBounds.width > viewport.width;
|
||||
if (isEndOOB) {
|
||||
targetLeft = Math.max(toggleRect.inlineStart - menuBounds.width, 0);
|
||||
}
|
||||
|
||||
menu.style.top = toggleRect.top + 'px';
|
||||
menu.style.left = targetLeft + 'px';
|
||||
menu.style.top = toggleRect.blockStart + 'px';
|
||||
menu.style.insetInlineStart = 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);
|
||||
const isEndOOB = toggleRect.inlineStart + menuBounds.width > viewport.width;
|
||||
let targetLeft = toggleRect.inlineStart;
|
||||
if (isEndOOB) {
|
||||
targetLeft = Math.max(toggleRect.inlineEnd - menuBounds.width, 0);
|
||||
}
|
||||
|
||||
menu.style.top = toggleRect.bottom + 'px';
|
||||
menu.style.left = targetLeft + 'px';
|
||||
menu.style.top = toggleRect.blockEnd + 'px';
|
||||
menu.style.insetInlineStart = targetLeft + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +41,7 @@ export class DropDownManager {
|
||||
|
||||
protected dropdownOptions: WeakMap<HTMLElement, HandleDropdownParams> = new WeakMap();
|
||||
protected openDropdowns: Set<HTMLElement> = new Set();
|
||||
protected isRTL: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.onMenuMouseOver = this.onMenuMouseOver.bind(this);
|
||||
@@ -46,6 +50,10 @@ export class DropDownManager {
|
||||
window.addEventListener('click', this.onWindowClick);
|
||||
}
|
||||
|
||||
setIsRTL(isRTL: boolean): void {
|
||||
this.isRTL = isRTL;
|
||||
}
|
||||
|
||||
teardown(): void {
|
||||
window.removeEventListener('click', this.onWindowClick);
|
||||
}
|
||||
@@ -80,7 +88,7 @@ export class DropDownManager {
|
||||
protected closeDropdown(menu: HTMLElement): void {
|
||||
menu.hidden = true;
|
||||
menu.style.removeProperty('position');
|
||||
menu.style.removeProperty('left');
|
||||
menu.style.removeProperty('inset-inline-start');
|
||||
menu.style.removeProperty('top');
|
||||
|
||||
this.openDropdowns.delete(menu);
|
||||
@@ -94,8 +102,8 @@ export class DropDownManager {
|
||||
|
||||
protected openDropdown(menu: HTMLElement): void {
|
||||
const {toggle, showAside, onOpen} = this.getOptions(menu);
|
||||
menu.hidden = false
|
||||
positionMenu(menu, toggle, Boolean(showAside));
|
||||
menu.hidden = false;
|
||||
positionMenu(menu, toggle, Boolean(showAside), this.isRTL);
|
||||
|
||||
this.openDropdowns.add(menu);
|
||||
menu.addEventListener('mouseover', this.onMenuMouseOver);
|
||||
|
||||
@@ -29,7 +29,8 @@ export class EditorUIManager {
|
||||
setContext(context: EditorUiContext) {
|
||||
this.context = context;
|
||||
this.setupEventListeners();
|
||||
this.setupEditor(context.editor);
|
||||
this.setupEditor(context.editor, context);
|
||||
this.dropdowns.setIsRTL(this.context.manager.getDefaultDirection() === 'rtl');
|
||||
}
|
||||
|
||||
getContext(): EditorUiContext {
|
||||
@@ -166,8 +167,10 @@ export class EditorUIManager {
|
||||
|
||||
triggerLayoutUpdate(): void {
|
||||
window.requestAnimationFrame(() => {
|
||||
const toolbarBounds: (DOMRect|null)[] = [];
|
||||
for (const toolbar of this.activeContextToolbars) {
|
||||
toolbar.updatePosition();
|
||||
const bounds = toolbar.updatePosition(toolbarBounds);
|
||||
toolbarBounds.push(bounds);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -246,17 +249,22 @@ export class EditorUIManager {
|
||||
}
|
||||
}
|
||||
|
||||
const toolbarBounds: (DOMRect|null)[] = [];
|
||||
for (const [target, contents] of contentByTarget) {
|
||||
const toolbar = new EditorContextToolbar(target, contents);
|
||||
toolbar.setContext(this.getContext());
|
||||
this.activeContextToolbars.push(toolbar);
|
||||
|
||||
this.getContext().containerDOM.append(toolbar.getDOMElement());
|
||||
toolbar.updatePosition();
|
||||
const bounds = toolbar.updatePosition(toolbarBounds);
|
||||
toolbarBounds.push(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
protected setupEditor(editor: LexicalEditor) {
|
||||
protected setupEditor(editor: LexicalEditor, context: EditorUiContext) {
|
||||
// Pass the context to the editor
|
||||
editor.setUiContext(context);
|
||||
|
||||
// Register our DOM decorate listener with the editor
|
||||
const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
|
||||
editor.getEditorState().read(() => {
|
||||
|
||||
@@ -18,13 +18,30 @@ export class EditorContextToolbar extends EditorContainerUiElement {
|
||||
}
|
||||
|
||||
protected buildDOM(): HTMLElement {
|
||||
return el('div', {
|
||||
const toolbar = el('div', {
|
||||
class: 'editor-context-toolbar',
|
||||
}, this.getChildren().map(child => child.getDOMElement()));
|
||||
|
||||
// Focus back on the editor on escape press
|
||||
toolbar.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.getContext().editor.focus();
|
||||
}
|
||||
}, {signal: this.abortController.signal});
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
const editorBounds = this.getContext().scrollDOM.getBoundingClientRect();
|
||||
/**
|
||||
* Update the position of the toolbar based on the target element.
|
||||
* Takes the bounds of other toolbars processed so far so that they can be considered
|
||||
* when positioning the toolbar to help prevent overlaps.
|
||||
*/
|
||||
updatePosition(otherBounds: (DOMRect|null)[] = []): DOMRect|null {
|
||||
const context = this.getContext();
|
||||
const editorBounds = context.scrollDOM.getBoundingClientRect();
|
||||
const targetBounds = this.target.getBoundingClientRect();
|
||||
const dom = this.getDOMElement();
|
||||
const domBounds = dom.getBoundingClientRect();
|
||||
@@ -36,23 +53,43 @@ export class EditorContextToolbar extends EditorContainerUiElement {
|
||||
|
||||
if (!this.target.isConnected) {
|
||||
// If our target is no longer in the DOM, tell the manager an update is needed.
|
||||
this.getContext().manager.triggerFutureStateRefresh();
|
||||
return;
|
||||
context.manager.triggerFutureStateRefresh();
|
||||
return null;
|
||||
} else if (!showing) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const showAbove: boolean = targetBounds.bottom + 6 + domBounds.height > editorBounds.bottom;
|
||||
dom.classList.toggle('is-above', showAbove);
|
||||
|
||||
const targetMid = targetBounds.left + (targetBounds.width / 2);
|
||||
const targetLeft = targetMid - (domBounds.width / 2);
|
||||
const intendedBounds: DOMRectInit = {
|
||||
x: targetMid - (domBounds.width / 2),
|
||||
y: targetBounds.bottom + 6,
|
||||
width: domBounds.width,
|
||||
height: domBounds.height,
|
||||
};
|
||||
|
||||
let showAbove: boolean = (
|
||||
targetBounds.bottom + 6 + domBounds.height > editorBounds.bottom
|
||||
|| this.willOverlapWithOthersIfBelow(intendedBounds, otherBounds)
|
||||
);
|
||||
dom.classList.toggle('is-above', showAbove);
|
||||
if (showAbove) {
|
||||
dom.style.top = (targetBounds.top - 6 - domBounds.height) + 'px';
|
||||
} else {
|
||||
dom.style.top = (targetBounds.bottom + 6) + 'px';
|
||||
intendedBounds.y = targetBounds.top - 6 - domBounds.height;
|
||||
}
|
||||
dom.style.left = targetLeft + 'px';
|
||||
|
||||
dom.style.top = intendedBounds.y + 'px';
|
||||
dom.style.left = intendedBounds.x + 'px';
|
||||
|
||||
// Set z-index based on depth, so that the most specific toolbar
|
||||
// is bought forward.
|
||||
let depth = 1;
|
||||
let parent = this.target.parentElement;
|
||||
while (parent && parent !== context.editorDOM) {
|
||||
parent = parent.parentElement;
|
||||
depth++;
|
||||
}
|
||||
dom.style.zIndex = `${depth}`;
|
||||
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
insert(children: EditorUiElement[]) {
|
||||
@@ -60,4 +97,27 @@ export class EditorContextToolbar extends EditorContainerUiElement {
|
||||
const dom = this.getDOMElement();
|
||||
dom.append(...children.map(child => child.getDOMElement()));
|
||||
}
|
||||
}
|
||||
|
||||
protected willOverlapWithOthersIfBelow(intendedBounds: DOMRectInit, otherBounds: (DOMRect|null)[]): boolean {
|
||||
for (const bounds of otherBounds) {
|
||||
if (bounds === null) continue;
|
||||
|
||||
const iLeft = intendedBounds.x ?? 0;
|
||||
const iTop = intendedBounds.y ?? 0;
|
||||
const iRight = iLeft + (intendedBounds.width ?? 0);
|
||||
const iBottom = iTop + (intendedBounds.height ?? 0);
|
||||
|
||||
const overlaps = (
|
||||
iLeft < bounds.right &&
|
||||
iRight > bounds.left &&
|
||||
iTop < bounds.bottom &&
|
||||
iBottom > bounds.top
|
||||
);
|
||||
|
||||
if (overlaps) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
107
resources/js/wysiwyg/utils/__tests__/actions.test.ts
Normal file
107
resources/js/wysiwyg/utils/__tests__/actions.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
createTestContext,
|
||||
destroyFromContext,
|
||||
expectNodeShapeToMatch,
|
||||
} from "lexical/__tests__/utils";
|
||||
import { $createParagraphNode, $createTextNode, $getRoot, LexicalEditor } from "lexical";
|
||||
import { EditorUiContext } from "../../ui/framework/core";
|
||||
import { setEditorContentFromHtml } from "../actions";
|
||||
|
||||
describe('Actions', () => {
|
||||
|
||||
let context!: EditorUiContext;
|
||||
let editor!: LexicalEditor;
|
||||
|
||||
beforeEach(() => {
|
||||
context = createTestContext();
|
||||
editor = context.editor;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyFromContext(context);
|
||||
});
|
||||
|
||||
describe('setEditorContentFromHtml', () => {
|
||||
it('parses HTML and sets content in editor', async () => {
|
||||
setEditorContentFromHtml(editor, '<p>Hello World</p>');
|
||||
await Promise.resolve().then();
|
||||
|
||||
expectNodeShapeToMatch(editor, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [{ text: 'Hello World' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses multiple block elements', async () => {
|
||||
setEditorContentFromHtml(editor, '<p>First</p><p>Second</p>');
|
||||
await Promise.resolve().then();
|
||||
|
||||
expectNodeShapeToMatch(editor, [
|
||||
{ type: 'paragraph', children: [{ text: 'First' }] },
|
||||
{ type: 'paragraph', children: [{ text: 'Second' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('wraps plain text in a paragraph', async () => {
|
||||
setEditorContentFromHtml(editor, 'Plain text');
|
||||
await Promise.resolve().then();
|
||||
|
||||
expectNodeShapeToMatch(editor, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [{ text: 'Plain text' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ensures at least a paragraph when HTML is empty', async () => {
|
||||
setEditorContentFromHtml(editor, '');
|
||||
await Promise.resolve().then();
|
||||
|
||||
expectNodeShapeToMatch(editor, [
|
||||
{ type: 'paragraph' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ensures at least a paragraph when HTML contains only whitespace', async () => {
|
||||
setEditorContentFromHtml(editor, ' ');
|
||||
await Promise.resolve().then();
|
||||
|
||||
expectNodeShapeToMatch(editor, [
|
||||
{ type: 'paragraph' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears existing content before setting new content', async () => {
|
||||
editor.updateAndCommit(() => {
|
||||
const p = $createParagraphNode();
|
||||
p.append($createTextNode('Existing'));
|
||||
$getRoot().append(p);
|
||||
});
|
||||
|
||||
setEditorContentFromHtml(editor, '<p>New</p>');
|
||||
await Promise.resolve().then();
|
||||
|
||||
expectNodeShapeToMatch(editor, [
|
||||
{ type: 'paragraph', children: [{ text: 'New' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles nested HTML structures', async () => {
|
||||
setEditorContentFromHtml(editor, '<ul><li>Item A</li><li>Item B</li></ul>');
|
||||
await Promise.resolve().then();
|
||||
|
||||
expectNodeShapeToMatch(editor, [
|
||||
{
|
||||
type: 'list',
|
||||
children: [
|
||||
{ type: 'listitem', children: [{ text: 'Item A' }] },
|
||||
{ type: 'listitem', children: [{ text: 'Item B' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import {$getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical";
|
||||
import {$createParagraphNode, $getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical";
|
||||
import {$generateHtmlFromNodes} from "@lexical/html";
|
||||
import {$getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes";
|
||||
|
||||
@@ -12,6 +12,12 @@ export function setEditorContentFromHtml(editor: LexicalEditor, html: string) {
|
||||
|
||||
const nodes = $htmlToBlockNodes(editor, html);
|
||||
root.append(...nodes);
|
||||
|
||||
// Always ensure we at least have a paragraph in the root
|
||||
// as a target for the cursor/focus/actions.
|
||||
if (root.isEmpty()) {
|
||||
root.append($createParagraphNode());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,5 +91,14 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise<string> {
|
||||
}
|
||||
|
||||
export function focusEditor(editor: LexicalEditor): void {
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const selection = $getSelection();
|
||||
const firstChild = root.getFirstChild();
|
||||
if (firstChild && !selection) {
|
||||
firstChild.selectStart();
|
||||
}
|
||||
});
|
||||
editor.commitUpdates();
|
||||
editor.focus(() => {}, {defaultSelection: "rootStart"});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
$createTextNode,
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isParagraphNode,
|
||||
$isParagraphNode, $isRangeSelection,
|
||||
LexicalEditor,
|
||||
LexicalNode
|
||||
} from "lexical";
|
||||
@@ -16,10 +16,12 @@ import {
|
||||
} from "./selection";
|
||||
import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode";
|
||||
import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "@lexical/rich-text/LexicalCalloutNode";
|
||||
import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list";
|
||||
import {$isListItemNode, $isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list";
|
||||
import {$createLinkNode, $isLinkNode} from "@lexical/link";
|
||||
import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode";
|
||||
import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
|
||||
import {$setBlocksType} from "@lexical/selection";
|
||||
|
||||
|
||||
const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => {
|
||||
return $isHeadingNode(node) && node.getTag() === tag;
|
||||
@@ -62,28 +64,35 @@ export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) {
|
||||
}
|
||||
|
||||
export function formatCodeBlock(editor: LexicalEditor) {
|
||||
editor.getEditorState().read(() => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
const lastSelection = getLastSelection(editor);
|
||||
const codeBlock = $getNodeFromSelection(lastSelection, $isCodeBlockNode) as (CodeBlockNode | null);
|
||||
if (codeBlock === null) {
|
||||
editor.update(() => {
|
||||
const codeBlock = $createCodeBlockNode();
|
||||
codeBlock.setCode(selection?.getTextContent() || '');
|
||||
const codeBlock = $createCodeBlockNode();
|
||||
|
||||
const selectionNodes = $getBlockElementNodesInSelection(selection);
|
||||
const firstSelectionNode = selectionNodes[0];
|
||||
const extraNodes = selectionNodes.slice(1);
|
||||
if (firstSelectionNode) {
|
||||
firstSelectionNode.replace(codeBlock);
|
||||
extraNodes.forEach(n => n.remove());
|
||||
} else {
|
||||
$insertNewBlockNodeAtSelection(codeBlock, true);
|
||||
}
|
||||
const codeLines = [];
|
||||
const selectionNodes = $getBlockElementNodesInSelection(selection);
|
||||
for (const node of selectionNodes) {
|
||||
codeLines.push(node.getTextContent());
|
||||
}
|
||||
codeBlock.setCode(codeLines.join('\n'));
|
||||
|
||||
$openCodeEditorForNode(editor, codeBlock);
|
||||
$selectSingleNode(codeBlock);
|
||||
});
|
||||
const firstSelectionNode = selectionNodes[0];
|
||||
const extraNodes = selectionNodes.slice(1);
|
||||
if ($isListItemNode(firstSelectionNode)) {
|
||||
firstSelectionNode.getChildren().forEach(c => c.remove());
|
||||
firstSelectionNode.append(codeBlock);
|
||||
extraNodes.forEach(n => n.remove());
|
||||
} else if (firstSelectionNode) {
|
||||
firstSelectionNode.replace(codeBlock);
|
||||
extraNodes.forEach(n => n.remove());
|
||||
} else {
|
||||
$insertNewBlockNodeAtSelection(codeBlock, true);
|
||||
}
|
||||
|
||||
$openCodeEditorForNode(editor, codeBlock);
|
||||
$selectSingleNode(codeBlock);
|
||||
} else {
|
||||
$openCodeEditorForNode(editor, codeBlock);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical";
|
||||
import {$createParagraphNode, $createTextNode, $getSelection, BaseSelection, LexicalEditor, ParagraphNode, TextNode} from "lexical";
|
||||
import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
|
||||
import {$sortNodes, nodeHasInset} from "./nodes";
|
||||
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
|
||||
import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode, ListNode} from "@lexical/list";
|
||||
|
||||
|
||||
export function $nestListItem(node: ListItemNode): ListItemNode {
|
||||
@@ -186,4 +186,31 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo
|
||||
}
|
||||
|
||||
$toggleSelection(editor);
|
||||
}
|
||||
}
|
||||
|
||||
export function $escapeListAtItem(item: ListItemNode): ParagraphNode|null {
|
||||
const list = item.getParentOrThrow<ListNode>();
|
||||
const parentListItem = list.getParent();
|
||||
|
||||
// Un-nest list item if it's an empty nested item
|
||||
if ($isListItemNode(parentListItem)) {
|
||||
parentListItem.insertAfter(item);
|
||||
item.selectStart();
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we're anywhere but at the end, split the list, with following items
|
||||
// moved into their own new list.
|
||||
if (!item.isLastChild()) {
|
||||
const afterSiblings = item.getNextSiblings();
|
||||
const newList = $createListNode(list.getListType());
|
||||
newList.append(...afterSiblings);
|
||||
list.insertAfter(newList);
|
||||
}
|
||||
|
||||
// Insert a new empty paragraph to land on after our list
|
||||
const paragraph = $createParagraphNode();
|
||||
list.insertAfter(paragraph, true);
|
||||
item.remove();
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getRoot,
|
||||
$isDecoratorNode,
|
||||
$isElementNode, $isRootNode,
|
||||
@@ -64,6 +65,27 @@ export function $getAllNodesOfType(matcher: LexicalNodeMatcher, root?: ElementNo
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node based on the given mouse event.
|
||||
*/
|
||||
export function $getNodePositionFromMouseEvent(event: MouseEvent, editor: LexicalEditor): {node: LexicalNode, offset: number}|null {
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
const caretPosition = window.document.caretPositionFromPoint(event.x, event.y);
|
||||
if (!caretPosition) {
|
||||
const backup = $getNearestBlockNodeForCoords(editor, x, y);
|
||||
return backup ? {node: backup, offset: 0} : null;
|
||||
}
|
||||
|
||||
const node = $getNearestNodeFromDOMNode(caretPosition.offsetNode);
|
||||
if (!node) {
|
||||
const backup = $getNearestBlockNodeForCoords(editor, x, y);
|
||||
return backup ? {node: backup, offset: 0} : null;
|
||||
}
|
||||
|
||||
return {node, offset: caretPosition.offset};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the nearest root/block level node for the given position.
|
||||
*/
|
||||
|
||||
49
resources/js/wysiwyg/utils/rtl.ts
Normal file
49
resources/js/wysiwyg/utils/rtl.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Create a viewport relative rect for an element which provides
|
||||
* logical property support.
|
||||
*/
|
||||
export class RTLRect {
|
||||
protected rect: DOMRect;
|
||||
protected isRTL: boolean;
|
||||
|
||||
constructor(element: HTMLElement, isRTL = false) {
|
||||
this.rect = element.getBoundingClientRect();
|
||||
this.isRTL = isRTL;
|
||||
}
|
||||
|
||||
get blockStart(): number {
|
||||
return this.rect.top;
|
||||
}
|
||||
|
||||
get inlineStart(): number {
|
||||
if (!this.isRTL) {
|
||||
return this.rect.left;
|
||||
}
|
||||
|
||||
return window.innerWidth - this.rect.right;
|
||||
}
|
||||
|
||||
get blockEnd(): number {
|
||||
return this.rect.bottom;
|
||||
}
|
||||
|
||||
get inlineEnd(): number {
|
||||
if (!this.isRTL) {
|
||||
return this.rect.right;
|
||||
}
|
||||
|
||||
return window.innerWidth - this.rect.left;
|
||||
}
|
||||
|
||||
get width(): number {
|
||||
return this.rect.width;
|
||||
}
|
||||
|
||||
get height(): number {
|
||||
return this.rect.height;
|
||||
}
|
||||
}
|
||||
|
||||
export function getViewportRect(): RTLRect {
|
||||
return new RTLRect(document.documentElement, false);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
$createNodeSelection,
|
||||
$createParagraphNode, $createRangeSelection,
|
||||
$createParagraphNode, $createRangeSelection, $getEditor, $getNearestNodeFromDOMNode,
|
||||
$getRoot,
|
||||
$getSelection, $isBlockElementNode, $isDecoratorNode,
|
||||
$isElementNode, $isParagraphNode,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
$setSelection,
|
||||
BaseSelection, DecoratorNode,
|
||||
ElementNode, LexicalEditor,
|
||||
LexicalNode,
|
||||
LexicalNode, RangeSelection,
|
||||
TextFormatType, TextNode
|
||||
} from "lexical";
|
||||
import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
|
||||
@@ -17,6 +17,8 @@ import {$setBlocksType} from "@lexical/selection";
|
||||
|
||||
import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes";
|
||||
import {CommonBlockAlignment} from "lexical/nodes/common";
|
||||
import {$isListItemNode} from "@lexical/list";
|
||||
import {$createCollapsedRangeSelectionForNode} from "lexical/LexicalSelection";
|
||||
|
||||
const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>;
|
||||
|
||||
@@ -76,9 +78,33 @@ export function $selectionContainsTextFormat(selection: BaseSelection | null, fo
|
||||
return false;
|
||||
}
|
||||
|
||||
function createNewBlockIfSelectionIsSingleListItemText(selection: BaseSelection): void {
|
||||
const startEnd = selection.getStartEndPoints();
|
||||
if (!startEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startBlock = $getNearestNodeBlockParent(startEnd[0].getNode());
|
||||
const endBlock = $getNearestNodeBlockParent(startEnd[1].getNode());
|
||||
const isSingleListItemTextSelection = $isListItemNode(startBlock) && startBlock.getKey() === endBlock?.getKey();
|
||||
|
||||
if (isSingleListItemTextSelection) {
|
||||
const wrapper = $createParagraphNode();
|
||||
const startNode = startEnd[0].getNode();
|
||||
startNode.insertBefore(wrapper);
|
||||
wrapper.append(...selection.getNodes());
|
||||
}
|
||||
}
|
||||
|
||||
export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) {
|
||||
const selection = $getSelection();
|
||||
const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null;
|
||||
|
||||
const inListItem = $isListItemNode(blockElement);
|
||||
if (inListItem && selection) {
|
||||
createNewBlockIfSelectionIsSingleListItemText(selection);
|
||||
}
|
||||
|
||||
if (selection && matcher(blockElement)) {
|
||||
$setBlocksType(selection, $createParagraphNode);
|
||||
} else {
|
||||
@@ -109,6 +135,35 @@ export function $insertNewBlockNodesAtSelection(nodes: LexicalNode[], insertAfte
|
||||
}
|
||||
}
|
||||
|
||||
export function $insertNewNodesAtSelection(nodes: LexicalNode[]) {
|
||||
const selection = $getSelection();
|
||||
if (selection) {
|
||||
selection.insertNodes(nodes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do something relatively sensible if we don't have a selection within view
|
||||
const root = $getRoot();
|
||||
let targetBlock = root.getLastChild();
|
||||
for (const node of nodes) {
|
||||
const isBlock = $isBlockElementNode(node);
|
||||
if (isBlock && !targetBlock) {
|
||||
root.append(node);
|
||||
targetBlock = node;
|
||||
} else if (isBlock) {
|
||||
targetBlock?.insertAfter(node);
|
||||
targetBlock = node;
|
||||
} else if ($isElementNode(targetBlock)) {
|
||||
targetBlock.append(node);
|
||||
} else {
|
||||
const paragraph = $createParagraphNode();
|
||||
paragraph.append(node);
|
||||
root.append(paragraph);
|
||||
targetBlock = paragraph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function $selectSingleNode(node: LexicalNode) {
|
||||
const nodeSelection = $createNodeSelection();
|
||||
nodeSelection.add(node.getKey());
|
||||
@@ -246,4 +301,39 @@ export function $getDecoratorNodesInSelection(selection: BaseSelection | null):
|
||||
}
|
||||
|
||||
return selection.getNodes().filter(node => $isDecoratorNode(node));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to select the given node at roughly the pixel offset, relative to the left of the node.
|
||||
* Returns the range selection if a selection could be made.
|
||||
* Returns null if no selection can be made.
|
||||
*/
|
||||
export function $selectNodeAtXPixelOffset(node: LexicalNode, pixelOffset: number, targetStart: boolean = true): RangeSelection|null {
|
||||
const targetDOM = $getEditor().getElementByKey(node.getKey());
|
||||
if (!targetDOM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetChild = targetDOM.children[targetStart ? 0 : targetDOM.children.length - 1] || targetDOM;
|
||||
const targetBounds = targetChild.getBoundingClientRect();
|
||||
const targetY = targetBounds[targetStart ? 'top' : 'bottom'] + (targetStart ? 1 : -1);
|
||||
const targetX = targetBounds.x + pixelOffset;
|
||||
// Temporary caretRangeFromPoint usage due to caretPositionFromPoint being only
|
||||
// very recently supported in Safari
|
||||
// To remove post 2026
|
||||
const caretRange = document.caretRangeFromPoint?.(targetX, targetY);
|
||||
const caret = document.caretPositionFromPoint?.(targetX, targetY)
|
||||
?? (caretRange ? { offsetNode: caretRange.startContainer, offset: caretRange.startOffset } : undefined);
|
||||
if (!caret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetNode = $getNearestNodeFromDOMNode(caret.offsetNode);
|
||||
if (!targetNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rangeSelection = $createCollapsedRangeSelectionForNode(targetNode, caret.offset);
|
||||
$setSelection(rangeSelection);
|
||||
return rangeSelection;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
$isTableCellNode,
|
||||
$isTableNode,
|
||||
$isTableRowNode,
|
||||
$isTableSelection, TableCellNode, TableNode,
|
||||
$isTableSelection, TableCellHeaderStates, TableCellNode, TableNode,
|
||||
TableRowNode,
|
||||
TableSelection,
|
||||
} from "@lexical/table";
|
||||
@@ -328,9 +328,21 @@ export function $getCellPaddingForTable(table: TableNode): string {
|
||||
return padding || '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Toggle the header state of the cells in the provided row.
|
||||
* Returns a boolean to indicate if the new state of the cells is as headers.
|
||||
*/
|
||||
export function $toggleRowCellHeaderState(row: TableRowNode): boolean {
|
||||
const firstCell = row.getFirstChild();
|
||||
const isHeader = $isTableCellNode(firstCell) ? firstCell.getHeaderStyles() !== TableCellHeaderStates.NO_STATUS : false;
|
||||
const cells = row.getChildren();
|
||||
for (const cell of cells) {
|
||||
if ($isTableCellNode(cell)) {
|
||||
cell.setHeaderStyles(isHeader ? TableCellHeaderStates.NO_STATUS : TableCellHeaderStates.ROW);
|
||||
}
|
||||
}
|
||||
return !isHeader;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -461,6 +461,7 @@ body.editor-is-fullscreen {
|
||||
z-index: 3;
|
||||
user-select: none;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease-in-out;
|
||||
&:hover, &.active {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
table {
|
||||
min-width: 100px;
|
||||
max-width: 100%;
|
||||
thead {
|
||||
@include mixins.lightDark(background-color, #f8f8f8, #333);
|
||||
font-weight: 500;
|
||||
}
|
||||
td, th {
|
||||
min-width: 10px;
|
||||
padding: 6px 8px;
|
||||
@@ -22,6 +18,15 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
// Table Header styles.
|
||||
// Initial selector for in-body th cells, intended to (somewhat) not conflict with
|
||||
// previous approach (second selector) to table headers where
|
||||
// we would target the thead specifically.
|
||||
table:not(:has(thead)) th, table thead {
|
||||
@include mixins.lightDark(background-color, #f8f8f8, #333);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
table.table {
|
||||
width: 100%;
|
||||
tr td, tr th {
|
||||
|
||||
@@ -12,12 +12,16 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Set fonts to common system fonts, starting with DejaVu Sans due to support in DOMPDF
|
||||
body, h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
@@ -100,4 +104,4 @@ body.export-format-pdf.export-engine-dompdf {
|
||||
.page-content td a > img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="flex-container-row items-center gap-m">
|
||||
<span class="api-method text-mono" data-method="{{ $endpoint['method'] }}">{{ $endpoint['method'] }}</span>
|
||||
<h5 id="{{ $endpoint['name'] }}" class="text-mono pb-xs">
|
||||
@if($endpoint['controller_method_kebab'] === 'list')
|
||||
@if(str_starts_with($endpoint['controller_method_kebab'], 'list') && !str_contains($endpoint['uri'], '{'))
|
||||
<a style="color: inherit;" target="_blank" rel="noopener" href="{{ url($endpoint['uri']) }}">{{ url($endpoint['uri']) }}</a>
|
||||
@else
|
||||
<span>{{ url($endpoint['uri']) }}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p class="mb-none">
|
||||
This documentation covers use of the REST API. <br>
|
||||
Examples of API usage, in a variety of programming languages, can be found in the <a href="https://codeberg.org/bookstack/api-scripts" target="_blank" rel="noopener noreferrer">BookStack api-scripts repo on GitHub</a>.
|
||||
Examples of API usage, in a variety of programming languages, can be found in the <a href="https://codeberg.org/bookstack/api-scripts" target="_blank" rel="noopener noreferrer">BookStack api-scripts repo on Codeberg</a>.
|
||||
|
||||
<br> <br>
|
||||
Some alternative options for extension and customization can be found below:
|
||||
@@ -14,11 +14,11 @@
|
||||
HTTP POST calls upon events occurring in BookStack.
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/BookStackApp/BookStack/blob/development/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> -
|
||||
<a href="https://codeberg.org/bookstack/bookstack/src/branch/development/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> -
|
||||
Methods to override views, translations and icons within BookStack.
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/BookStackApp/BookStack/blob/development/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> -
|
||||
<a href="https://codeberg.org/bookstack/bookstack/src/branch/development/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> -
|
||||
Methods to extend back-end functionality within BookStack.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
<h1 class="list-heading">{{ trans('entities.books_sort') }}</h1>
|
||||
|
||||
<div class="flex-container-row gap-m wrap mb-m">
|
||||
<p class="text-muted flex min-width-s mb-none">{{ trans('entities.books_sort_desc') }}</p>
|
||||
<div class="flex min-width-s">
|
||||
<p class="text-muted mb-none">{{ trans('entities.books_sort_desc') }}</p>
|
||||
@if(!userCan(\BookStack\Permissions\Permission::SettingsManage))
|
||||
<p class="text-muted mb-none mt-s small">{{ trans('entities.books_sort_auto_sort_creation_hint') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="min-width-s">
|
||||
@php
|
||||
$autoSortVal = intval(old('auto-sort') ?? $book->sort_rule_id ?? 0);
|
||||
@@ -41,6 +46,11 @@
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if(userCan(\BookStack\Permissions\Permission::SettingsManage))
|
||||
<p class="small">
|
||||
<a href="{{ url('/settings/sorting/rules/new') }}" class="text-button" target="_blank">{{ trans('settings.sort_rule_create') }}</a>
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($entity->isA('page'))
|
||||
@if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll))
|
||||
<a href="{{ $entity->getUrl('/revisions') }}" class="entity-meta-item">
|
||||
@icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
|
||||
</a>
|
||||
|
||||
@@ -113,13 +113,13 @@
|
||||
<a href="https://www.bookstackapp.com/docs/admin/debugging/" target="_blank">Review BookStack debugging documentation »</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/BookStackApp/BookStack/releases" target="_blank">Ensure your instance is up-to-date »</a>
|
||||
<a href="https://codeberg.org/bookstack/bookstack/releases" target="_blank">Ensure your instance is up-to-date »</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue+{{ urlencode($error) }}" target="_blank">Search for the issue on GitHub »</a>
|
||||
<a href="https://codeberg.org/bookstack/bookstack/issues?q={{ urlencode($error) }}" target="_blank">Search for the issue on GitHub »</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://discord.gg/ztkBqR2" target="_blank">Ask for help via Discord »</a>
|
||||
<a href="https://community.bookstackapp.com" target="_blank">Ask for help in our community forums »</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://duckduckgo.com/?q={{urlencode("BookStack {$error}")}}" target="_blank">Search the error message »</a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="entity-meta">
|
||||
@if ($entity->isA('page'))
|
||||
@if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll))
|
||||
@icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} <br>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
</a>
|
||||
@endif
|
||||
@endif
|
||||
<a href="{{ $page->getUrl('/revisions') }}" data-shortcut="revisions" class="icon-list-item">
|
||||
<span>@icon('history')</span>
|
||||
<span>{{ trans('entities.revisions') }}</span>
|
||||
</a>
|
||||
@if(userCan(\BookStack\Permissions\Permission::RevisionViewAll))
|
||||
<a href="{{ $page->getUrl('/revisions') }}" data-shortcut="revisions" class="icon-list-item">
|
||||
<span>@icon('history')</span>
|
||||
<span>{{ trans('entities.revisions') }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $page))
|
||||
<a href="{{ $page->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
|
||||
<span>@icon('lock')</span>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
{{-- <div id="lexical-debug" style="white-space: pre-wrap; font-size: 12px; height: 200px; overflow-y: scroll; background-color: #000; padding: 1rem; border-radius: 4px; color: #FFF;"></div>--}}
|
||||
|
||||
<textarea refs="wysiwyg-editor@input" id="html-editor" hidden="hidden" name="html" rows="5">{{ old('html') ?? $model->html ?? '' }}</textarea>
|
||||
<textarea refs="wysiwyg-editor@input" id="html-editor" hidden="hidden" name="html" rows="5">{{ (old('html') ?? $model->html ?? '') ?: '<p></p>' }}</textarea>
|
||||
</div>
|
||||
|
||||
@if($errors->has('html'))
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
<div>
|
||||
<label for="setting-sorting-book-default"
|
||||
class="setting-list-label">{{ trans('settings.sorting_book_default') }}</label>
|
||||
<p class="small">{{ trans('settings.sorting_book_default_desc') }}</p>
|
||||
<p class="small mb-s">{{ trans('settings.sorting_book_default_desc') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="pt-s">
|
||||
<select id="setting-sorting-book-default" name="setting-sorting-book-default"
|
||||
@if($errors->has('setting-sorting-book-default')) class="neg" @endif>
|
||||
<option value="0" @if(intval(setting('sorting-book-default', '0')) === 0) selected @endif>
|
||||
@@ -54,6 +54,9 @@
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="small">
|
||||
<a href="{{ url('/settings/sorting/rules/new') }}" class="text-button" target="_blank">{{ trans('settings.sort_rule_create') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<h5 class="mt-xl">{{ trans('settings.system_version') }}</h5>
|
||||
<div class="py-xs">
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://codeberg.org/bookstack/bookstack/releases">
|
||||
BookStack @if(!str_starts_with($version, 'v')) version @endif {{ $version }}
|
||||
</a>
|
||||
<br>
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
@include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.books'), 'permissionPrefix' => 'book'])
|
||||
@include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.chapters'), 'permissionPrefix' => 'chapter'])
|
||||
@include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.pages'), 'permissionPrefix' => 'page'])
|
||||
@include('settings.roles.parts.revisions-permissions-row', ['title' => trans('entities.revisions'), 'permissionPrefix' => 'revision'])
|
||||
@include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image'])
|
||||
@include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.attachments'), 'permissionPrefix' => 'attachment'])
|
||||
@include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.comments'), 'permissionPrefix' => 'comment'])
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<div class="item-list-row flex-container-row items-center wrap">
|
||||
<div class="flex py-s px-m min-width-s">
|
||||
<strong>{{ $title }}</strong> <br>
|
||||
<a href="#" refs="permissions-table@toggle-row" class="text-small text-link">{{ trans('common.toggle_all') }}</a>
|
||||
</div>
|
||||
<div class="flex py-s px-m min-width-xxs">
|
||||
<small class="hide-over-m bold">{{ trans('common.create') }}<br></small>
|
||||
<strong class="text-muted opacity-70 text-large">-</strong>
|
||||
</div>
|
||||
<div class="flex py-s px-m min-width-xxs">
|
||||
<small class="hide-over-m bold">{{ trans('common.view') }}<br></small>
|
||||
@include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')])
|
||||
</div>
|
||||
<div class="flex py-s px-m min-width-xxs">
|
||||
<small class="hide-over-m bold">{{ trans('common.edit') }}<br></small>
|
||||
<strong class="text-muted opacity-70 text-large">-</strong>
|
||||
</div>
|
||||
<div class="flex py-s px-m min-width-xxs">
|
||||
<small class="hide-over-m bold">{{ trans('common.delete') }}<br></small>
|
||||
<small>{{ trans('settings.role_controlled_by_page_delete') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user