Merge branch 'development' into clauvaldez/mfaReset

This commit is contained in:
Dan Brown
2026-05-17 11:59:41 +01:00
395 changed files with 6513 additions and 2297 deletions

View 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

View File

@@ -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', {

View File

@@ -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),
);

View File

@@ -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(

View File

@@ -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

View File

@@ -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)) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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);
}
});
}

View File

@@ -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 {

View File

@@ -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')) {

View File

@@ -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&nbsp;</p>',
' hello': '<p>&nbsp;hello</p>',
' hello ': '<p>&nbsp;hello&nbsp;</p>',
'hello ': '<p>hello&nbsp; &nbsp;</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>&nbsp;Hello <strong>there</strong> is <em>text</em> here&nbsp;</p>');
});
await update(() => {
const html = $generateHtmlFromNodes($getEditor(), null);
expect(html).toBe('<p>&nbsp;Hello <strong>there</strong> is <em>text</em> here&nbsp;</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 &nbsp; <strong>world&nbsp; &nbsp;</strong></p>');
});
});
test('text with formats exports using format elements instead of classes', async () => {
await update(() => {
const paragraph = $getRoot().getFirstChild<ElementNode>()!;

View File

@@ -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(

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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),
);
}

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>`);
});
});

View File

@@ -1,7 +1,6 @@
import {
$createTextNode,
DOMConversionMap,
DOMExportOutput,
EditorConfig,
ElementNode,
LexicalEditor,

View File

@@ -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,
);

View File

@@ -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
);
}

View 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);
});
});

View File

@@ -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);
};
}

View File

@@ -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) => {

View File

@@ -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";

View File

@@ -221,7 +221,7 @@ export const detailsEditLabel: EditorButtonDefinition = {
if ($isDetailsNode(details)) {
$showDetailsForm(details, context);
}
})
});
},
isActive(selection: BaseSelection | null): boolean {
return false;

View File

@@ -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',

View File

@@ -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: [

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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(() => {

View File

@@ -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;
}
}

View 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' }] },
],
},
]);
});
});
});

View File

@@ -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"});
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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.
*/

View 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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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%;
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -113,13 +113,13 @@
<a href="https://www.bookstackapp.com/docs/admin/debugging/" target="_blank">Review BookStack debugging documentation &raquo;</a>
</li>
<li>
<a href="https://github.com/BookStackApp/BookStack/releases" target="_blank">Ensure your instance is up-to-date &raquo;</a>
<a href="https://codeberg.org/bookstack/bookstack/releases" target="_blank">Ensure your instance is up-to-date &raquo;</a>
</li>
<li>
<a href="https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue+{{ urlencode($error) }}" target="_blank">Search for the issue on GitHub &raquo;</a>
<a href="https://codeberg.org/bookstack/bookstack/issues?q={{ urlencode($error) }}" target="_blank">Search for the issue on GitHub &raquo;</a>
</li>
<li>
<a href="https://discord.gg/ztkBqR2" target="_blank">Ask for help via Discord &raquo;</a>
<a href="https://community.bookstackapp.com" target="_blank">Ask for help in our community forums &raquo;</a>
</li>
<li>
<a href="https://duckduckgo.com/?q={{urlencode("BookStack {$error}")}}" target="_blank">Search the error message &raquo;</a>

View File

@@ -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

View File

@@ -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>

View File

@@ -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'))

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'])

View File

@@ -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>