Compare commits

...

22 Commits

Author SHA1 Message Date
Elian Doran
f729e5b379 style(lexical): improve toolbar 2026-03-20 18:30:56 +02:00
Elian Doran
6e8c4813d9 fix(lexical): toolbar icon alignment 2026-03-20 18:28:51 +02:00
Elian Doran
029e8469b4 feat(lexical): add icons to toolbar and basic styling 2026-03-20 18:27:28 +02:00
Elian Doran
715b87fb3d chore(lexical): start working on a toolbar 2026-03-20 18:11:43 +02:00
Elian Doran
3d860ba2c4 feat(lexical): integrate scroll to end 2026-03-20 18:05:40 +02:00
Elian Doran
8b479ac4ba fix(lexical): unnecessary outline 2026-03-20 18:03:04 +02:00
Elian Doran
dcfcc7a334 fix(lexical): missing margin 2026-03-20 18:02:07 +02:00
Elian Doran
daa0109f7f fix(lexical): placeholder display issue 2026-03-20 18:00:47 +02:00
Elian Doran
f46e20aa25 feat(lexical): clear history when switching notes 2026-03-20 17:54:08 +02:00
Elian Doran
e8045424b2 feat(lexical): basic read 2026-03-20 17:51:23 +02:00
Elian Doran
7e6b628f86 feat(lexical): write without reading 2026-03-20 17:43:32 +02:00
Elian Doran
fc247fe790 feat(lexical): add very basic integration 2026-03-20 17:34:29 +02:00
Elian Doran
7a2fa2e829 feat(lexical): switch editable text based on MIME 2026-03-20 17:25:20 +02:00
Elian Doran
3e3f455b24 feat(lexical): integrate different MIME type for text 2026-03-20 17:17:44 +02:00
Elian Doran
55dea474e9 chore(deps): update dependency @ckeditor/ckeditor5-dev-build-tools to v55.2.0 (#9099) 2026-03-20 13:45:51 +02:00
Elian Doran
bc74455a64 chore(deps): update dependency @smithy/middleware-retry to v4.4.44 (#9111) 2026-03-20 13:45:21 +02:00
Elian Doran
2d0b28367f chore(deps): update dependency vite to v8.0.1 (#9112) 2026-03-20 13:45:00 +02:00
Elian Doran
7d8a3e2811 fix(deps): update dependency katex to v0.16.39 (#9114) 2026-03-20 13:44:32 +02:00
renovate[bot]
79e5d9595a chore(deps): update dependency @ckeditor/ckeditor5-dev-build-tools to v55.2.0 2026-03-20 00:11:04 +00:00
renovate[bot]
0310626025 fix(deps): update dependency katex to v0.16.39 2026-03-20 00:08:50 +00:00
renovate[bot]
fefbb40c03 chore(deps): update dependency vite to v8.0.1 2026-03-20 00:07:33 +00:00
renovate[bot]
12f89078b8 chore(deps): update dependency @smithy/middleware-retry to v4.4.44 2026-03-20 00:06:57 +00:00
20 changed files with 947 additions and 343 deletions

View File

@@ -24,6 +24,7 @@
"@fullcalendar/multimonth": "6.1.20",
"@fullcalendar/rrule": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@lexical/react": "0.42.0",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.1",
"@mind-elixir/node-menu": "5.0.1",
@@ -58,9 +59,10 @@
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.38",
"katex": "0.16.39",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"lexical": "0.42.0",
"mark.js": "8.11.1",
"marked": "17.0.4",
"mermaid": "11.13.0",

View File

@@ -39,6 +39,7 @@ export interface MenuCommandItem<T> {
title: string;
command?: T;
type?: string;
mime?: string;
/**
* The icon to display in the menu item.
*

View File

@@ -288,7 +288,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
}
async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
async selectMenuItemHandler({ command, type, mime, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
const notePath = treeService.getNotePath(this.node);
if (utils.isMobile()) {
@@ -305,6 +305,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
target: "after",
targetBranchId: this.node.data.branchId,
type,
mime,
isProtected,
templateNoteId
});
@@ -313,6 +314,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
noteCreateService.createNote(parentNotePath, {
type,
mime,
isProtected: this.node.data.isProtected,
templateNoteId
});

View File

@@ -26,6 +26,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
// The default note type (always the first item)
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
{ type: "text", mime: "application/json", title: "Text (Lexical)", icon: "bx-note" },
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
// Text notes group
@@ -97,6 +98,7 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandName
title: nt.title,
command,
type: nt.type,
mime: nt.mime,
uiIcon: `bx ${nt.icon}`,
badges: []
};

View File

@@ -14,10 +14,11 @@ import note_create from "../../../services/note_create";
import options from "../../../services/options";
import toast from "../../../services/toast";
import utils, { hasTouchBar, isMobile } from "../../../services/utils";
import { useEditorSpacedUpdate, useLegacyImperativeHandlers, useNoteLabel, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import { useEditorSpacedUpdate, useLegacyImperativeHandlers, useNoteLabel, useNoteProperty, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import TouchBar, { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl } from "../../react/TouchBar";
import { TypeWidgetProps } from "../type_widget";
import CKEditorWithWatchdog, { CKEditorApi } from "./CKEditorWithWatchdog";
import LexicalText from "./lexical";
import getTemplates, { updateTemplateCache } from "./snippets.js";
import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./utils";
@@ -27,7 +28,15 @@ import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./util
* - Ballon block mode, in which there is a floating toolbar for the selected text, but another floating button for the entire block (i.e. paragraph).
* - Decoupled mode, in which the editing toolbar is actually added on the client side (in {@link ClassicEditorToolbar}), see https://ckeditor.com/docs/ckeditor5/latest/examples/framework/bottom-toolbar-editor.html for an example on how the decoupled editor works.
*/
export default function EditableText({ note, parentComponent, ntxId, noteContext }: TypeWidgetProps) {
export default function EditableText(props: TypeWidgetProps) {
const mime = useNoteProperty(props.note, "mime");
if (mime === "application/json") {
return <LexicalText {...props} />;
}
return <EditableTextCKEditor {...props} />;
}
function EditableTextCKEditor({ note, parentComponent, ntxId, noteContext }: TypeWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<string>("");
const watchdogRef = useRef<EditorWatchdog>(null);

View File

@@ -0,0 +1,41 @@
.note-detail-editable-text {
.toolbar {
display: flex;
margin-bottom: 1px;
background: var(--classic-toolbar-vert-layout-background-color);
padding: 3px 6px;
border-radius: 6px;
margin: 20px;
vertical-align: middle;
}
.toolbar .divider {
width: 1px;
background-color: var(--main-border-color);
margin: 0 6px;
}
.toolbar .toolbar-item .text {
display: flex;
line-height: 20px;
width: 200px;
vertical-align: middle;
font-size: 14px;
color: #777;
text-overflow: ellipsis;
width: 70px;
overflow: hidden;
height: 20px;
text-align: left;
}
.toolbar .toolbar-item .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
}
}

View File

@@ -0,0 +1,177 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import "./ToolbarPlugin.css";
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {mergeRegister} from '@lexical/utils';
import {
$getSelection,
$isRangeSelection,
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
COMMAND_PRIORITY_LOW,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
REDO_COMMAND,
SELECTION_CHANGE_COMMAND,
UNDO_COMMAND,
} from 'lexical';
import {useCallback, useEffect, useRef, useState} from 'react';
import ActionButton, { ActionButtonProps } from "../../../react/ActionButton";
function Divider() {
return <div className="divider" />;
}
export default function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const $updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
}
}, []);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({editorState}) => {
editorState.read(
() => {
$updateToolbar();
},
{editor},
);
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, _newEditor) => {
$updateToolbar();
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload);
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload);
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, $updateToolbar]);
return (
<div className="toolbar" ref={toolbarRef}>
<ToolbarButton
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
text="Undo"
icon="bx bx-undo"
/>
<ToolbarButton
disabled={!canRedo}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND, undefined);
}}
text="Redo"
icon="bx bx-redo"
/>
<Divider />
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
active={isBold}
text="Format Bold"
icon="bx bx-bold"
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
active={isItalic}
text="Format Italics"
icon="bx bx-italic"
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
active={isUnderline}
text="Format Underline"
icon="bx bx-underline"
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
active={isStrikethrough}
text="Format Strikethrough"
icon="bx bx-strikethrough"
/>
<Divider />
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
}}
text="Left Align"
icon="bx bx-align-left"
/>
<ToolbarButton
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
}}
text="Center Align"
icon="bx bx-align-middle"
/>
<ToolbarButton
onClick={() => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')}
text="Right Align"
icon="bx bx-align-right"
/>
<ToolbarButton
onClick={() => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')}
text="Justify Align"
icon="bx bx-align-justify"
/>{' '}
</div>
);
}
function ToolbarButton(props: Pick<ActionButtonProps, "icon" | "disabled" | "onClick" | "text">) {
return (
<ActionButton
className="toolbar-item"
{...props}
/>
);
}

View File

@@ -0,0 +1,21 @@
.note-detail-editable-text .lexical-wrapper {
color: var(--main-text-color);
font-family: var(--main-font-family);
font-size: var(--main-font-size);
line-height: 1.5;
word-break: break-word;
position: relative;
margin-inline: var(--content-margin-inline);
>div[contenteditable="true"] {
outline: 0;
}
.lexical-placeholder {
opacity: 0.5;
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
}

View File

@@ -0,0 +1,120 @@
import "./index.css";
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {$createRangeSelection, $getRoot, $setSelection, CLEAR_HISTORY_COMMAND} from 'lexical';
import { useEffect } from 'preact/hooks';
import { useEditorSpacedUpdate, useTriliumEvent } from '../../../react/hooks';
import { TypeWidgetProps } from "../../type_widget";
import ToolbarPlugin from "./ToolbarPlugin";
const theme = {
// Theme styling goes here
//...
};
// Catch any errors that occur during Lexical updates and log them
// or throw them as needed. If you don't throw them, Lexical will
// try to recover gracefully without losing user data.
function onError(error) {
console.error(error);
}
export default function LexicalText(props: TypeWidgetProps) {
const initialConfig = {
namespace: 'MyEditor',
theme,
onError,
};
const placeholder = (
<div className="lexical-placeholder">
Enter some text...
</div>
);
return (
<LexicalComposer initialConfig={initialConfig}>
<ToolbarPlugin />
<div className="lexical-wrapper">
<RichTextPlugin
contentEditable={<ContentEditable /> as never}
placeholder={placeholder as never}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
<HistoryPlugin />
<AutoFocusPlugin />
<ScrollToEndPlugin />
<CustomEditorPersistencePlugin {...props} />
</LexicalComposer>
);
}
function CustomEditorPersistencePlugin({ note, noteContext }: TypeWidgetProps) {
const [editor] = useLexicalComposerContext();
const spacedUpdate = useEditorSpacedUpdate({
note,
noteContext,
noteType: "text",
getData() {
return {
content: JSON.stringify(editor.toJSON().editorState)
};
},
onContentChange(newContent) {
if (!newContent) {
editor.update(() => {
$getRoot().clear();
});
return;
}
try {
const editorState = editor.parseEditorState(newContent);
editor.setEditorState(editorState);
} catch (err) {
console.error("Error parsing Lexical content", err);
}
},
});
// Clear the history whenever note changes.
useEffect(() => {
editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
}, [ editor, note ]);
// Detect changes in content.
useEffect(() => {
return editor.registerUpdateListener(() => {
spacedUpdate.scheduleUpdate();
});
}, [ spacedUpdate, editor ]);
}
function ScrollToEndPlugin() {
const [editor] = useLexicalComposerContext();
useTriliumEvent("scrollToEnd", () => {
editor.update(() => {
const root = $getRoot();
const lastChild = root.getLastDescendant();
if (lastChild) {
const selection = $createRangeSelection();
selection.anchor.set(lastChild.getKey(), lastChild.getTextContentSize(), 'text');
selection.focus.set(lastChild.getKey(), lastChild.getTextContentSize(), 'text');
$setSelection(selection);
}
});
editor.focus();
});
return null;
}

View File

@@ -126,7 +126,7 @@
"tmp": "0.2.5",
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "8.0.0",
"vite": "8.0.1",
"ws": "8.19.0",
"xml2js": "0.6.2",
"yauzl": "3.2.1"

View File

@@ -22,7 +22,7 @@
"eslint-config-preact": "2.0.0",
"typescript": "5.9.3",
"user-agent-data-types": "0.4.2",
"vite": "8.0.0",
"vite": "8.0.1",
"vitest": "4.1.0"
},
"eslintConfig": {

View File

@@ -76,7 +76,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"upath": "2.0.1",
"vite": "8.0.0",
"vite": "8.0.1",
"vite-plugin-dts": "4.5.4",
"vitest": "4.1.0"
},

View File

@@ -21,7 +21,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -22,7 +22,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -16,7 +16,7 @@
"ckeditor5-premium-features": "47.6.1"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.43",
"@smithy/middleware-retry": "4.4.44",
"@types/jquery": "4.0.0"
}
}

View File

@@ -25,7 +25,7 @@
"license": "Apache-2.0",
"dependencies": {
"fuse.js": "7.1.0",
"katex": "0.16.38",
"katex": "0.16.39",
"mermaid": "11.13.0"
},
"devDependencies": {

887
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff