Merge remote-tracking branch 'origin/develop' into find_replace

This commit is contained in:
Elian Doran 2025-05-10 15:26:52 +03:00
commit 553b07ab37
No known key found for this signature in database
311 changed files with 25334 additions and 2475 deletions

View File

@ -56,7 +56,7 @@ jobs:
run: npx playwright install --with-deps
- name: Run the TypeScript build
run: npm nx run server:build
run: pnpm run server:build
- name: Build and export to Docker
uses: docker/build-push-action@v6
@ -183,7 +183,7 @@ jobs:
id: build
uses: docker/build-push-action@v6
with:
context: .
context: apps/server
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}

5
.gitignore vendored
View File

@ -40,4 +40,7 @@ test-output
apps/*/data
apps/*/out
upload
upload
.rollup.cache
*.tsbuildinfo

2
.mailmap Normal file
View File

@ -0,0 +1,2 @@
Adam Zivner <adam.zivner@gmail.com>
Adam Zivner <zadam.apps@gmail.com>

View File

@ -1,48 +1,3 @@
/* !!!!!! TRILIUM CUSTOM CHANGES !!!!!! */
.printed-content .ck-widget__selection-handle, .printed-content .ck-widget__type-around { /* gets rid of triangles: https://github.com/zadam/trilium/issues/1129 */
display: none;
}
.page-break {
page-break-after: always;
}
.printed-content .page-break:after,
.printed-content .page-break > * {
display: none !important;
}
.ck-content li p {
margin: 0 !important;
}
.admonition {
--accent-color: var(--card-border-color);
border: 1px solid var(--accent-color);
box-shadow: var(--card-box-shadow);
background: var(--card-background-color);
border-radius: 0.5em;
padding: 1em;
margin: 1.25em 0;
position: relative;
overflow: hidden;
}
.admonition p:last-child {
margin-bottom: 0;
}
.admonition p, h2 {
margin-top: 0;
}
.admonition.note { --accent-color: #69c7ff; }
.admonition.tip { --accent-color: #40c025; }
.admonition.important { --accent-color: #9839f7; }
.admonition.caution { --accent-color: #ff2e2e; }
.admonition.warning { --accent-color: #e2aa03; }
/*
* CKEditor 5 (v41.0.0) content styles.
* Generated on Fri, 26 Jan 2024 10:23:49 GMT.

View File

@ -0,0 +1,30 @@
.ck.ck-sticky-panel > .ck-progress-bar {
display: flex;
flex-direction: row;
justify-content: space-between;
border-left: 1px solid var(--ck-color-base-border);
border-top: 1px solid var(--ck-color-base-border);
border-right: 1px solid var(--ck-color-base-border);
}
.ck.ck-sticky-panel > .ck-progress-bar > .ck-uploading-progress {
align-self: center;
padding: 3px 5px;
font-weight: bold;
color: var(--ck-color-base-foreground);
background-color: var(--ck-color-base-border);
transition-property: width;
transition-duration: .5s;
transition-timing-function: linear;
}
.ck.ck-sticky-panel > .ck-progress-bar > .ck-uploading-cancel {
align-self: flex-end;
padding: 0 5px;
font-weight: bold;
color: var(--ck-color-base-error);
}

View File

@ -38,10 +38,10 @@
"@playwright/test": "1.52.0",
"@stylistic/eslint-plugin": "4.2.0",
"@types/express": "5.0.1",
"@types/node": "22.15.3",
"@types/node": "22.15.17",
"@types/yargs": "17.0.33",
"@vitest/coverage-v8": "3.1.2",
"eslint": "9.24.0",
"@vitest/coverage-v8": "3.1.3",
"eslint": "9.26.0",
"eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25",
"jsdoc": "4.0.4",

View File

@ -10,7 +10,7 @@
"url": "https://github.com/TriliumNext/Notes"
},
"dependencies": {
"@eslint/js": "9.25.0",
"@eslint/js": "9.26.0",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.17",
"@fullcalendar/daygrid": "6.1.17",
@ -21,16 +21,17 @@
"@mermaid-js/layout-elk": "0.1.7",
"@mind-elixir/node-menu": "1.0.5",
"@popperjs/core": "2.11.8",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/commons": "workspace:*",
"bootstrap": "5.3.5",
"bootstrap": "5.3.6",
"dayjs": "1.11.13",
"dayjs-plugin-utc": "0.1.2",
"debounce": "2.2.0",
"draggabilly": "3.0.0",
"eslint-linter-browserify": "9.26.0",
"force-graph": "1.49.5",
"globals": "16.0.0",
"i18next": "25.0.2",
"globals": "16.1.0",
"i18next": "25.1.2",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery-hotkeys": "0.2.2",
@ -44,19 +45,21 @@
"mermaid": "11.6.0",
"mind-elixir": "4.5.2",
"panzoom": "9.4.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "4.1.0",
"@types/bootstrap": "5.2.10",
"@types/jquery": "3.5.32",
"@types/leaflet": "1.9.17",
"@types/leaflet-gpx": "1.3.7",
"@types/react": "18.3.20",
"@types/react-dom": "18.3.6",
"@types/react": "19.1.3",
"@types/react-dom": "19.1.3",
"copy-webpack-plugin": "13.0.0",
"happy-dom": "17.4.6",
"script-loader": "0.7.2"
},

View File

@ -26,6 +26,7 @@ import type TypeWidget from "../widgets/type_widgets/type_widget.js";
import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js";
import type { NativeImage, TouchBar } from "electron";
import TouchBarComponent from "./touch_bar.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
interface Layout {
getRootWidget: (appContext: AppContext) => RootWidget;
@ -187,7 +188,7 @@ export type CommandMappings = {
callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
};
executeWithTextEditor: CommandData &
ExecuteCommandData<TextEditor> & {
ExecuteCommandData<CKTextEditor> & {
callback?: GetTextEditorCallback;
};
executeWithCodeEditor: CommandData & ExecuteCommandData<CodeMirrorInstance>;

View File

@ -10,13 +10,14 @@ import options from "../services/options.js";
import type { ViewScope } from "../services/link.js";
import type FNote from "../entities/fnote.js";
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
export interface SetNoteOpts {
triggerSwitchEvent?: unknown;
viewScope?: ViewScope;
}
export type GetTextEditorCallback = (editor: TextEditor) => void;
export type GetTextEditorCallback = (editor: CKTextEditor) => void;
class NoteContext extends Component implements EventListener<"entitiesReloaded"> {
ntxId: string | null;
@ -298,7 +299,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
}
async getTextEditor(callback?: GetTextEditorCallback) {
return this.timeout<TextEditor>(
return this.timeout<CKTextEditor>(
new Promise((resolve) =>
appContext.triggerCommand("executeWithTextEditor", {
callback,

View File

@ -1,49 +0,0 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
import { DecoupledEditor as DecoupledEditorBase } from '@ckeditor/ckeditor5-editor-decoupled';
import { Essentials } from '@ckeditor/ckeditor5-essentials';
import { Alignment } from '@ckeditor/ckeditor5-alignment';
import { FontSize, FontFamily, FontColor, FontBackgroundColor } from '@ckeditor/ckeditor5-font';
import { CKFinderUploadAdapter } from '@ckeditor/ckeditor5-adapter-ckfinder';
import { Autoformat } from '@ckeditor/ckeditor5-autoformat';
import { Bold, Italic, Strikethrough, Underline } from '@ckeditor/ckeditor5-basic-styles';
import { BlockQuote } from '@ckeditor/ckeditor5-block-quote';
import { CKBox } from '@ckeditor/ckeditor5-ckbox';
import { CKFinder } from '@ckeditor/ckeditor5-ckfinder';
import { EasyImage } from '@ckeditor/ckeditor5-easy-image';
import { Heading } from '@ckeditor/ckeditor5-heading';
import { Image, ImageCaption, ImageResize, ImageStyle, ImageToolbar, ImageUpload, PictureEditing } from '@ckeditor/ckeditor5-image';
import { Indent, IndentBlock } from '@ckeditor/ckeditor5-indent';
import { Link } from '@ckeditor/ckeditor5-link';
import { List, ListProperties } from '@ckeditor/ckeditor5-list';
import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed';
import { Paragraph } from '@ckeditor/ckeditor5-paragraph';
import { PasteFromOffice } from '@ckeditor/ckeditor5-paste-from-office';
import { Table, TableToolbar } from '@ckeditor/ckeditor5-table';
import { TextTransformation } from '@ckeditor/ckeditor5-typing';
import { CloudServices } from '@ckeditor/ckeditor5-cloud-services';
export default class DecoupledEditor extends DecoupledEditorBase {
static builtinPlugins: (typeof TextTransformation | typeof Essentials | typeof Alignment | typeof FontBackgroundColor | typeof FontColor | typeof FontFamily | typeof FontSize | typeof CKFinderUploadAdapter | typeof Paragraph | typeof Heading | typeof Autoformat | typeof Bold | typeof Italic | typeof Strikethrough | typeof Underline | typeof BlockQuote | typeof Image | typeof ImageCaption | typeof ImageResize | typeof ImageStyle | typeof ImageToolbar | typeof ImageUpload | typeof CloudServices | typeof CKBox | typeof CKFinder | typeof EasyImage | typeof List | typeof ListProperties | typeof Indent | typeof IndentBlock | typeof Link | typeof MediaEmbed | typeof PasteFromOffice | typeof Table | typeof TableToolbar | typeof PictureEditing)[];
static defaultConfig: {
toolbar: {
items: string[];
};
image: {
resizeUnit: "px";
toolbar: string[];
};
table: {
contentToolbar: string[];
};
list: {
properties: {
styles: boolean;
startIndex: boolean;
reversed: boolean;
};
};
language: string;
};
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -9,7 +9,7 @@ export interface Froca {
branches: Record<string, FBranch>;
attributes: Record<string, FAttribute>;
attachments: Record<string, FAttachment>;
blobPromises: Record<string, Promise<void | FBlob> | null>;
blobPromises: Record<string, Promise<void | FBlob | null> | null>;
getBlob(entityType: string, entityId: string): Promise<FBlob | null>;
getNote(noteId: string, silentNotFoundError?: boolean): Promise<FNote | null>;

View File

@ -36,7 +36,7 @@ class FrocaImpl implements Froca {
branches!: Record<string, FBranch>;
attributes!: Record<string, FAttribute>;
attachments!: Record<string, FAttachment>;
blobPromises!: Record<string, Promise<FBlob> | null>;
blobPromises!: Record<string, Promise<FBlob | null> | null>;
constructor() {
this.initializedPromise = this.loadInitialTree();

View File

@ -7,10 +7,6 @@ export interface Library {
css?: string[];
}
const CKEDITOR: Library = {
js: ["libraries/ckeditor/ckeditor.js"]
};
const CODE_MIRROR: Library = {
js: () => {
const scriptsToLoad = [
@ -156,7 +152,6 @@ export default {
requireCss,
requireLibrary,
loadHighlightingTheme,
CKEDITOR,
CODE_MIRROR,
KATEX,
HIGHLIGHT_JS

View File

@ -38,7 +38,7 @@ let mimeToHighlightJsMapping: Record<string, string> | null = null;
* @param mimeType The MIME type of the code block, in the CKEditor-normalized format (e.g. `text-c-src` instead of `text/c-src`).
* @returns the corresponding highlight.js tag, for example `c` for `text-c-src`.
*/
function getHighlightJsNameForMime(mimeType: string) {
export function getHighlightJsNameForMime(mimeType: string) {
if (!mimeToHighlightJsMapping) {
const mimeTypes = getMimeTypes();
mimeToHighlightJsMapping = {};

View File

@ -3,6 +3,7 @@ import appContext from "../components/app_context.js";
import noteCreateService from "./note_create.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5";
// this key needs to have this value, so it's hit by the tooltip
const SELECTED_NOTE_PATH_KEY = "data-note-path";
@ -43,7 +44,7 @@ interface Options {
}
async function autocompleteSourceForCKEditor(queryText: string) {
return await new Promise<MentionItem[]>((res, rej) => {
return await new Promise<MentionFeedObjectItem[]>((res, rej) => {
autocompleteSource(
queryText,
(rows) => {

View File

@ -9,6 +9,7 @@ import { t } from "./i18n.js";
import type FNote from "../entities/fnote.js";
import type FBranch from "../entities/fbranch.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
interface CreateNoteOpts {
isProtected?: boolean;
@ -22,7 +23,7 @@ interface CreateNoteOpts {
focus?: "title" | "content";
target?: string;
targetBranchId?: string;
textEditor?: TextEditor;
textEditor?: CKTextEditor;
}
interface Response {

View File

@ -3,4 +3,9 @@ declare module "*.png" {
export default path;
}
declare module "script-loader!mark.js/dist/jquery.mark.min.js";
declare module "*.json?external" {
var path: string;
export default path;
}
declare module "script-loader!mark.js/dist/jquery.mark.min.js";

View File

@ -21,7 +21,7 @@ interface CustomGlobals {
getHeaders: typeof server.getHeaders;
getReferenceLinkTitle: (href: string) => Promise<string>;
getReferenceLinkTitleSync: (href: string) => string;
getActiveContextNote: () => FNote;
getActiveContextNote: () => FNote | null;
requireLibrary: typeof library_loader.requireLibrary;
ESLINT: Library;
appContext: AppContext;
@ -74,6 +74,9 @@ declare global {
type AutoCompleteCallback = (values: AutoCompleteArg[]) => void;
interface AutoCompleteArg {
name?: string;
value?: string;
notePathTitle?: string;
displayKey?: "name" | "value" | "notePathTitle";
cache?: boolean;
source?: (term: string, cb: AutoCompleteCallback) => void,
@ -83,7 +86,7 @@ declare global {
}
interface JQuery {
autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: object[] | string) => JQuery<HTMLElement>;
autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: AutoCompleteArg[] | string) => JQuery<HTMLElement>;
getSelectedNotePath(): string | undefined;
getSelectedNoteId(): string | null;
@ -131,56 +134,6 @@ declare global {
var renderMathInElement: (element: HTMLElement, options: {
trust: boolean;
}) => void;
interface CKCodeBlockLanguage {
language: string;
label: string;
}
interface CKEditorInstance {
create(elementOrData: any, finalConfig: any): TextEditor;
}
class CKWatchdog {
constructor(editorClass: CKEditorInstance, opts: {
minimumNonErrorTimePeriod: number;
crashNumberLimit: number,
saveInterval: number
});
on(event: string, callback: () => void);
state: string;
crashes: unknown[];
editor: TextEditor;
setCreator(callback: (elementOrData, editorConfig) => void);
create(el: HTMLElement, opts: {
placeholder: string,
mention: MentionConfig,
codeBlock: {
languages: CKCodeBlockLanguage[]
},
math: {
engine: string,
outputType: string,
lazyLoad: () => Promise<void>,
forceOutputType: boolean,
enablePreview: boolean
},
mermaid: {
lazyLoad: () => Promise<Mermaid>,
config: MermaidConfig
}
});
destroy();
}
var CKEditor: {
BalloonEditor: CKEditorInstance;
DecoupledEditor: CKEditorInstance;
EditorWatchdog: typeof CKWatchdog;
};
var CKEditorInspector: {
attach(editor: TextEditor);
};
interface CodeMirrorOpts {
value: string;
@ -256,222 +209,6 @@ declare global {
});
}
interface Range {
toJSON(): object;
getItems(): TextNode[];
}
interface Writer {
setAttribute(name: string, value: string, el: CKNode);
createPositionAt(el: CKNode, opt?: "end" | number);
setSelection(pos: number, pos2?: number);
insertText(text: string, opts: Record<string, unknown> | undefined | TextPosition, position?: TextPosition);
addMarker(name: string, opts: {
range: Range;
usingOperation: boolean;
});
removeMarker(name: string);
createRange(start: number, end: number): Range;
createElement(type: string, opts: Record<string, string | null | undefined>);
}
interface TextNode {
previousSibling?: TextNode;
name: string;
data: string;
startOffset: number;
_attrs: {
get(key: string): {
length: number
}
}
}
interface TextPosition {
textNode: TextNode;
offset: number;
compareWith(pos: TextPosition): string;
}
interface TextRange {
}
interface Marker {
name: string;
}
interface CKNode {
_children: CKNode[];
name: string;
childCount: number;
isEmpty: boolean;
toJSON(): object;
is(type: string, name?: string);
getAttribute(name: string): string;
getChild(index: number): CKNode;
data: string;
startOffset: number;
root: {
document: {
model: {
createRangeIn(el: CKNode): TextRange;
markers: {
getMarkersIntersectingRange(range: TextRange): Marker[];
}
}
}
};
}
interface CKEvent {
stop(): void;
}
interface PluginEventData {
title: string;
message: {
message: string;
};
}
interface TextEditor {
create(el: HTMLElement, config: {
removePlugins?: string[];
toolbar: {
items: any[];
},
placeholder: string;
mention: MentionConfig
});
enableReadOnlyMode(reason: string);
commands: {
get(name: string): {
value: unknown;
on(event: string, callback: () => void): void;
};
}
model: {
document: {
on(event: string, cb: () => void);
getRoot(): CKNode;
registerPostFixer(callback: (writer: Writer) => boolean);
selection: {
getFirstPosition(): undefined | TextPosition;
getLastPosition(): undefined | TextPosition;
getSelectedElement(): CKNode;
hasAttribute(attribute: string): boolean;
getAttribute(attribute: string): string;
getFirstRange(): Range;
isCollapsed: boolean;
};
differ: {
getChanges(): {
type: string;
name: string;
position?: {
nodeAfter?: CKNode;
parent: CKNode;
toJSON(): Object;
}
}[];
}
},
insertContent(modelFragment: any, selection?: any);
change(cb: (writer: Writer) => void)
},
editing: {
view: {
document: {
on(event: string, cb: (event: CKEvent, data: {
preventDefault();
}) => void, opts?: {
priority: "high"
});
getRoot(): CKNode
},
domRoots: {
values: () => {
next: () => {
value: string;
}
};
}
change(cb: (writer: Writer) => void);
scrollToTheSelection(): void;
focus(): void;
}
},
plugins: {
get(command: string)
},
data: {
processor: {
toView(html: string);
};
toModel(viewFeragment: any);
},
ui: {
view: {
toolbar: {
items: any[];
element: HTMLElement;
}
}
}
conversion: {
for(filter: string): {
markerToHighlight(data: {
model: string;
view: (data: {
markerName: string;
}) => void;
})
}
}
getData(): string;
setData(data: string): void;
getSelectedHtml(): string;
removeSelection(): void;
execute<T>(action: string, ...args: unknown[]): T;
focus(): void;
sourceElement: HTMLElement;
}
interface EditingState {
highlightedResult: string;
results: unknown[];
}
interface CKFindResult {
results: {
get(number): {
marker: {
getStart(): TextPosition;
getRange(): number;
};
}
} & [];
}
interface MentionItem {
action?: string;
noteTitle?: string;
id: string;
name: string;
link?: string;
notePath?: string;
highlightedNotePathTitle?: string;
}
interface MentionConfig {
feeds: {
marker: string;
feed: (queryText: string) => MentionItem[] | Promise<MentionItem[]>;
itemRenderer?: (item: {
highlightedNotePathTitle: string
}) => void;
minimumCharacters: number;
}[];
}
/*
* Panzoom
*/

View File

@ -1,10 +1,10 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import noteAutocompleteService, { type Suggestion } from "../../services/note_autocomplete.js";
import server from "../../services/server.js";
import contextMenuService from "../../menus/context_menu.js";
import attributeParser, { type Attribute } from "../../services/attribute_parser.js";
import libraryLoader from "../../services/library_loader.js";
import { AttributeEditor, type EditorConfig, type Element, type MentionFeed, type Node, type Position } from "@triliumnext/ckeditor5";
import froca from "../../services/froca.js";
import attributeRenderer from "../../services/attribute_renderer.js";
import noteCreateService from "../../services/note_create.js";
@ -15,7 +15,6 @@ import type { CommandData, EventData, EventListener, FilteredCommandNames } from
import type { default as FAttribute, AttributeType } from "../../entities/fattribute.js";
import type FNote from "../../entities/fnote.js";
import { escapeQuotes } from "../../services/utils.js";
import { buildConfig } from "../type_widgets/ckeditor/config.js";
const HELP_TEXT = `
<p>${t("attribute_editor.help_text_body1")}</p>
@ -85,109 +84,59 @@ const TPL = /*html*/`
</div>
`;
const mentionSetup: MentionConfig = {
feeds: [
{
marker: "@",
feed: (queryText) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
itemRenderer: (item) => {
const itemElement = document.createElement("button");
const mentionSetup: MentionFeed[] = [
{
marker: "@",
feed: (queryText) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
itemRenderer: (_item) => {
const item = _item as Suggestion;
const itemElement = document.createElement("button");
itemElement.innerHTML = `${item.highlightedNotePathTitle} `;
itemElement.innerHTML = `${item.highlightedNotePathTitle} `;
return itemElement;
},
minimumCharacters: 0
return itemElement;
},
{
marker: "#",
feed: async (queryText) => {
const names = await server.get<string[]>(`attribute-names/?type=label&query=${encodeURIComponent(queryText)}`);
minimumCharacters: 0
},
{
marker: "#",
feed: async (queryText) => {
const names = await server.get<string[]>(`attribute-names/?type=label&query=${encodeURIComponent(queryText)}`);
return names.map((name) => {
return {
id: `#${name}`,
name: name
};
});
},
minimumCharacters: 0
return names.map((name) => {
return {
id: `#${name}`,
name: name
};
});
},
{
marker: "~",
feed: async (queryText) => {
const names = await server.get<string[]>(`attribute-names/?type=relation&query=${encodeURIComponent(queryText)}`);
minimumCharacters: 0
},
{
marker: "~",
feed: async (queryText) => {
const names = await server.get<string[]>(`attribute-names/?type=relation&query=${encodeURIComponent(queryText)}`);
return names.map((name) => {
return {
id: `~${name}`,
name: name
};
});
},
minimumCharacters: 0
}
]
};
return names.map((name) => {
return {
id: `~${name}`,
name: name
};
});
},
minimumCharacters: 0
}
];
const editorConfig = {
...buildConfig(),
removePlugins: [
"Heading",
"Link",
"Autoformat",
"Bold",
"Italic",
"Underline",
"Strikethrough",
"Code",
"Superscript",
"Subscript",
"BlockQuote",
"Image",
"ImageCaption",
"ImageStyle",
"ImageToolbar",
"ImageUpload",
"ImageResize",
"List",
"TodoList",
"PasteFromOffice",
"Table",
"TableToolbar",
"TableProperties",
"TableCellProperties",
"Indent",
"IndentBlock",
"BlockToolbar",
"ParagraphButtonUI",
"HeadingButtonsUI",
"UploadimagePlugin",
"InternalLinkPlugin",
"MarkdownImportPlugin",
"CuttonotePlugin",
"TextTransformation",
"Font",
"FontColor",
"FontBackgroundColor",
"CodeBlock",
"SelectAll",
"IncludeNote",
"CutToNote",
"Math",
"AutoformatMath",
"indentBlockShortcutPlugin",
"removeFormatLinksPlugin",
"Footnotes",
"Mermaid",
"Kbd",
"Admonition"
],
const editorConfig: EditorConfig = {
toolbar: {
items: []
},
placeholder: t("attribute_editor.placeholder"),
mention: mentionSetup
mention: {
feeds: mentionSetup
},
licenseKey: "GPL"
};
type AttributeCommandNames = FilteredCommandNames<CommandData>;
@ -199,7 +148,7 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
private $saveAttributesButton!: JQuery<HTMLElement>;
private $errors!: JQuery<HTMLElement>;
private textEditor!: TextEditor;
private textEditor!: AttributeEditor;
private lastUpdatedNoteId!: string | undefined;
private lastSavedContent!: string;
@ -369,13 +318,11 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
}
async initEditor() {
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
this.$widget.show();
this.$editor.on("click", (e) => this.handleEditorClick(e));
this.textEditor = await CKEditor.BalloonEditor.create(this.$editor[0], editorConfig);
this.textEditor = await AttributeEditor.create(this.$editor[0], editorConfig);
this.textEditor.model.document.on("change:data", () => this.dataChanged());
this.textEditor.editing.view.document.on(
"enter",
@ -388,7 +335,10 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
);
// disable spellcheck for attribute editor
this.textEditor.editing.view.change((writer) => writer.setAttribute("spellcheck", "false", this.textEditor.editing.view.document.getRoot()));
const documentRoot = this.textEditor.editing.view.document.getRoot();
if (documentRoot) {
this.textEditor.editing.view.change((writer) => writer.setAttribute("spellcheck", "false", documentRoot));
}
}
dataChanged() {
@ -465,18 +415,18 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
this.$editor.tooltip("show");
}
getClickIndex(pos: TextPosition) {
let clickIndex = pos.offset - pos.textNode.startOffset;
getClickIndex(pos: Position) {
let clickIndex = pos.offset - (pos.textNode?.startOffset ?? 0);
let curNode = pos.textNode;
let curNode: Node | Text | Element | null = pos.textNode;
while (curNode.previousSibling) {
while (curNode?.previousSibling) {
curNode = curNode.previousSibling;
if (curNode.name === "reference") {
clickIndex += curNode._attrs.get("notePath").length + 1;
} else {
clickIndex += curNode.data.length;
if ((curNode as Element).name === "reference") {
clickIndex += (curNode.getAttribute("notePath") as string).length + 1;
} else if ("data" in curNode) {
clickIndex += (curNode.data as string).length;
}
}
@ -534,8 +484,12 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
this.$editor.trigger("focus");
this.textEditor.model.change((writer) => {
const positionAt = writer.createPositionAt(this.textEditor.model.document.getRoot(), "end");
const documentRoot = this.textEditor.editing.model.document.getRoot();
if (!documentRoot) {
return;
}
const positionAt = writer.createPositionAt(documentRoot, "end");
writer.setSelection(positionAt);
});
}

View File

@ -10,7 +10,7 @@ import utils from "../../services/utils.js";
import { Dropdown } from "bootstrap";
import type FAttachment from "../../entities/fattachment.js";
import type AttachmentDetailWidget from "../attachment_detail.js";
import { NoteRow } from "@triliumnext/commons";
import type { NoteRow } from "@triliumnext/commons";
const TPL = /*html*/`
<div class="dropdown attachment-actions">
@ -83,7 +83,7 @@ const TPL = /*html*/`
// TODO: Deduplicate
interface AttachmentResponse {
note: NoteRow;
note: NoteRow;
}
export default class AttachmentActionsWidget extends BasicWidget {

View File

@ -11,7 +11,7 @@ import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc.js";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import "../../stylesheets/calendar.css";
import { AttributeRow } from "@triliumnext/commons";
import type { AttributeRow } from "@triliumnext/commons";
dayjs.extend(utc);
dayjs.extend(isSameOrAfter);

View File

@ -1,3 +1,4 @@
import type { FindAndReplaceState, FindCommandResult } from "@triliumnext/ckeditor5";
import type { FindResult } from "./find.js";
import type FindWidget from "./find.js";
@ -14,8 +15,8 @@ interface Match {
export default class FindInText {
private parent: FindWidget;
private findResult?: CKFindResult | null;
private editingState?: EditingState;
private findResult?: FindCommandResult | null;
private editingState?: FindAndReplaceState;
constructor(parent: FindWidget) {
this.parent = parent;
@ -40,7 +41,7 @@ export default class FindInText {
// Clear
const findAndReplaceEditing = textEditor.plugins.get("FindAndReplaceEditing");
findAndReplaceEditing.state.clear(model);
findAndReplaceEditing.state?.clear(model);
findAndReplaceEditing.stop();
this.editingState = findAndReplaceEditing.state;
if (searchTerm !== "") {
@ -52,7 +53,7 @@ export default class FindInText {
// let m = text.match(re);
// totalFound = m ? m.length : 0;
const options = { matchCase: matchCase, wholeWords: wholeWord };
findResult = textEditor.execute<CKFindResult>("find", searchTerm, options);
findResult = textEditor.execute("find", searchTerm, options);
totalFound = findResult.results.length;
const selection = model.document.selection;
// If text is selected, highlight the corresponding result;
@ -60,17 +61,17 @@ export default class FindInText {
if (!selection.isCollapsed) {
const cursorPos = selection.getFirstPosition();
for (let i = 0; i < findResult.results.length; ++i) {
const marker = findResult.results.get(i).marker;
const fromPos = marker.getStart();
if (cursorPos && fromPos.compareWith(cursorPos) !== "before") {
const marker = findResult.results.get(i)?.marker;
const fromPos = marker?.getStart();
if (cursorPos && fromPos?.compareWith(cursorPos) !== "before") {
currentFound = i;
break;
}
}
} else {
const editorEl = textEditor?.sourceElement;
const findResultElement = editorEl.querySelectorAll(".ck-find-result");
const scrollingContainer = editorEl.closest('.scrolling-container');
const findResultElement = editorEl?.querySelectorAll(".ck-find-result");
const scrollingContainer = editorEl?.closest('.scrolling-container');
const containerTop = scrollingContainer?.getBoundingClientRect().top ?? 0;
const closestIndex = Array.from(findResultElement ?? []).findIndex((el) => el.getBoundingClientRect().top >= containerTop);
currentFound = closestIndex >= 0 ? closestIndex : 0;
@ -86,7 +87,7 @@ export default class FindInText {
// XXX Do this accessing the private data?
// See https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findnextcommand.js
for (let i = 0; i < currentFound; ++i) {
textEditor?.execute("findNext", searchTerm);
textEditor?.execute("findNext");
}
}
@ -120,17 +121,17 @@ export default class FindInText {
// Clear the markers and set the caret to the
// current occurrence
const model = textEditor.model;
const range = this.findResult?.results?.get(currentFound).marker.getRange();
const range = this.findResult?.results?.get(currentFound)?.marker?.getRange();
// From
// https://github.com/ckeditor/ckeditor5/blob/b95e2faf817262ac0e1e21993d9c0bde3f1be594/packages/ckeditor5-find-and-replace/src/findandreplace.js#L92
// XXX Roll our own since already done for codeEditor and
// will probably allow more refactoring?
let findAndReplaceEditing = textEditor.plugins.get("FindAndReplaceEditing");
findAndReplaceEditing.state.clear(model);
findAndReplaceEditing.state?.clear(model);
findAndReplaceEditing.stop();
if (range) {
model.change((writer) => {
writer.setSelection(range, 0);
writer.setSelection(range);
});
}
textEditor.editing.view.scrollToTheSelection();

View File

@ -1,6 +1,11 @@
import library_loader from "../../../services/library_loader.js";
import { ALLOWED_PROTOCOLS } from "../../../services/link.js";
import { MIME_TYPE_AUTO } from "../../../services/mime_type_definitions.js";
import { getHighlightJsNameForMime } from "../../../services/mime_types.js";
import options from "../../../services/options.js";
import { isSyntaxHighlightEnabled } from "../../../services/syntax_highlight.js";
import utils from "../../../services/utils.js";
import emojiDefinitionsUrl from "@triliumnext/ckeditor5/emoji_definitions/en.json?external";
const TEXT_FORMATTING_GROUP = {
label: "Text formatting",
@ -18,7 +23,6 @@ export function buildConfig() {
"alignBlockRight",
"alignLeft",
"alignRight",
"full", // full and side are for BC since the old images have been created with these styles
"side"
]
},
@ -96,6 +100,18 @@ export function buildConfig() {
defaultProtocol: "https://",
allowedProtocols: ALLOWED_PROTOCOLS
},
emoji: {
definitionsUrl: emojiDefinitionsUrl
},
syntaxHighlighting: {
async loadHighlightJs() {
await library_loader.requireLibrary(library_loader.HIGHLIGHT_JS);
return hljs;
},
mapLanguageName: getHighlightJsNameForMime,
defaultMimeType: MIME_TYPE_AUTO,
enabled: isSyntaxHighlightEnabled
},
// This value must be kept in sync with the language defined in webpack.config.js.
language: "en"
};
@ -169,7 +185,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) {
{
label: "Insert",
icon: "plus",
items: ["imageUpload", "|", "link", "internallink", "includeNote", "|", "specialCharacters", "math", "mermaid", "horizontalLine", "pageBreak"]
items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak"]
},
"|",
"outdent",
@ -202,6 +218,7 @@ export function buildFloatingToolbar() {
"|",
"code",
"link",
"bookmark",
"removeFormat",
"internallink",
"cuttonote"
@ -232,6 +249,7 @@ export function buildFloatingToolbar() {
"imageUpload",
"markdownImport",
"specialCharacters",
"emoji",
"findAndReplace"
]
};

View File

@ -1,360 +0,0 @@
/*
* This code is an adaptation of https://github.com/antoniotejada/Trilium-SyntaxHighlightWidget with additional improvements, such as:
*
* - support for selecting the language manually;
* - support for determining the language automatically, if a special language is selected ("Auto-detected");
* - limit for highlighting.
*
* TODO: Generally this class can be done directly in the CKEditor repository.
*/
import library_loader from "../../../services/library_loader.js";
import mime_types from "../../../services/mime_types.js";
import { isSyntaxHighlightEnabled } from "../../../services/syntax_highlight.js";
export async function initSyntaxHighlighting(editor: TextEditor) {
if (!isSyntaxHighlightEnabled) {
return;
}
await library_loader.requireLibrary(library_loader.HIGHLIGHT_JS);
initTextEditor(editor);
}
const HIGHLIGHT_MAX_BLOCK_COUNT = 500;
const tag = "SyntaxHighlightWidget";
const debugLevels = ["error", "warn", "info", "log", "debug"];
const debugLevel = debugLevels.indexOf("warn");
let warn = function (...args: unknown[]) {};
if (debugLevel >= debugLevels.indexOf("warn")) {
warn = console.warn.bind(console, tag + ": ");
}
let info = function (...args: unknown[]) {};
if (debugLevel >= debugLevels.indexOf("info")) {
info = console.info.bind(console, tag + ": ");
}
let log = function (...args: unknown[]) {};
if (debugLevel >= debugLevels.indexOf("log")) {
log = console.log.bind(console, tag + ": ");
}
let dbg = function (...args: unknown[]) {};
if (debugLevel >= debugLevels.indexOf("debug")) {
dbg = console.debug.bind(console, tag + ": ");
}
function assert(e: boolean, msg?: string) {
console.assert(e, tag + ": " + msg);
}
// TODO: Should this be scoped to note?
let markerCounter = 0;
function initTextEditor(textEditor: TextEditor) {
log("initTextEditor");
const document = textEditor.model.document;
// Create a conversion from model to view that converts
// hljs:hljsClassName:uniqueId into a span with hljsClassName
// See the list of hljs class names at
// https://github.com/highlightjs/highlight.js/blob/6b8c831f00c4e87ecd2189ebbd0bb3bbdde66c02/docs/css-classes-reference.rst
textEditor.conversion.for("editingDowncast").markerToHighlight({
model: "hljs",
view: ({ markerName }) => {
dbg("markerName " + markerName);
// markerName has the pattern addMarker:cssClassName:uniqueId
const [, cssClassName, id] = markerName.split(":");
// The original code at
// https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js
// has this comment
// Marker removal from the view has a bug:
// https://github.com/ckeditor/ckeditor5/issues/7499
// A minimal option is to return a new object for each converted marker...
return {
name: "span",
classes: [cssClassName],
attributes: {
// ...however, adding a unique attribute should be future-proof..
"data-syntax-result": id
}
};
}
});
// XXX This is done at BalloonEditor.create time, so it assumes this
// document is always attached to this textEditor, empirically that
// seems to be the case even with two splits showing the same note,
// it's not clear if CKEditor5 has apis to attach and detach
// documents around
document.registerPostFixer(function (writer) {
log("postFixer");
// Postfixers are a simpler way of tracking changes than onchange
// See
// https://github.com/ckeditor/ckeditor5/blob/b53d2a4b49679b072f4ae781ac094e7e831cfb14/packages/ckeditor5-block-quote/src/blockquoteediting.js#L54
const changes = document.differ.getChanges();
let dirtyCodeBlocks = new Set<CKNode>();
function lookForCodeBlocks(node: CKNode) {
for (const child of node._children) {
if (child.is("element", "paragraph")) {
continue;
}
if (child.is("element", "codeBlock")) {
dirtyCodeBlocks.add(child);
} else if (child.childCount > 0) {
lookForCodeBlocks(child);
}
}
}
for (const change of changes) {
dbg("change " + JSON.stringify(change));
if (change.name !== "paragraph" && change.name !== "codeBlock" && change?.position?.nodeAfter && change.position.nodeAfter.childCount > 0) {
/*
* We need to look for code blocks recursively, as they can be placed within a <div> due to
* general HTML support or normally underneath other elements such as tables, blockquotes, etc.
*/
lookForCodeBlocks(change.position.nodeAfter);
} else if (change.type == "insert" && change.name == "codeBlock") {
// A new code block was inserted
const codeBlock = change.position?.nodeAfter;
// Even if it's a new codeblock, it needs dirtying in case
// it already has children, like when pasting one or more
// full codeblocks, undoing a delete, changing the language,
// etc (the postfixer won't get later changes for those).
if (codeBlock) {
log("dirtying inserted codeBlock " + JSON.stringify(codeBlock.toJSON()));
dirtyCodeBlocks.add(codeBlock);
}
} else if (change.type == "remove" && change.name == "codeBlock" && change.position) {
// An existing codeblock was removed, do nothing. Note the
// node is no longer in the editor so the codeblock cannot
// be inspected here. No need to dirty the codeblock since
// it has been removed
log("removing codeBlock at path " + JSON.stringify(change.position.toJSON()));
} else if ((change.type == "remove" || change.type == "insert") && change?.position?.parent.is("element", "codeBlock")) {
// Text was added or removed from the codeblock, force a
// highlight
const codeBlock = change.position.parent;
log("dirtying codeBlock " + JSON.stringify(codeBlock.toJSON()));
dirtyCodeBlocks.add(codeBlock);
}
}
for (let codeBlock of dirtyCodeBlocks) {
highlightCodeBlock(codeBlock, writer);
}
// Adding markers doesn't modify the document data so no need for
// postfixers to run again
return false;
});
// This assumes the document is empty and a explicit call to highlight
// is not necessary here. Empty documents have a single children of type
// paragraph with no text
assert(document.getRoot().childCount == 1 && document.getRoot().getChild(0).name == "paragraph" && document.getRoot().getChild(0).isEmpty);
}
/**
* This implements highlighting via ephemeral markers (not stored in the
* document).
*
* XXX Another option would be to use formatting markers, which would have
* the benefit of making it work for readonly notes. On the flip side,
* the formatting would be stored with the note and it would need a
* way to remove that formatting when editing back the note.
*/
function highlightCodeBlock(codeBlock: CKNode, writer: Writer) {
log("highlighting codeblock " + JSON.stringify(codeBlock.toJSON()));
const model = codeBlock.root.document.model;
// Can't invoke addMarker with an already existing marker name,
// clear all highlight markers first. Marker names follow the
// pattern hljs:cssClassName:uniqueId, eg hljs:hljs-comment:1
const codeBlockRange = model.createRangeIn(codeBlock);
for (const marker of model.markers.getMarkersIntersectingRange(codeBlockRange)) {
dbg("removing marker " + marker.name);
writer.removeMarker(marker.name);
}
// Don't highlight if plaintext (note this needs to remove the markers
// above first, in case this was a switch from non plaintext to
// plaintext)
const mimeType = codeBlock.getAttribute("language");
if (mimeType == "text-plain") {
// XXX There's actually a plaintext language that could be used
// if you wanted the non-highlight formatting of
// highlight.js css applied, see
// https://github.com/highlightjs/highlight.js/issues/700
log("not highlighting plaintext codeblock");
return;
}
// Find the corresponding language for the given mimetype.
const highlightJsLanguage = mime_types.getHighlightJsNameForMime(mimeType);
if (mimeType !== mime_types.MIME_TYPE_AUTO && !highlightJsLanguage) {
console.warn(`Unsupported highlight.js for mime type ${mimeType}.`);
return;
}
// Don't highlight if the code is too big, as the typing performance will be highly degraded.
if (codeBlock.childCount >= HIGHLIGHT_MAX_BLOCK_COUNT) {
return;
}
// highlight.js needs the full text without HTML tags, eg for the
// text
// #include <stdio.h>
// the highlighted html is
// <span class="hljs-meta">#<span class="hljs-keyword">include</span> <span class="hljs-string">&lt;stdio.h&gt;</span></span>
// But CKEditor codeblocks have <br> instead of \n
// Do a two pass algorithm:
// - First pass collect the codeblock children text, change <br> to
// \n
// - invoke highlight.js on the collected text generating html
// - Second pass parse the highlighted html spans and match each
// char to the CodeBlock text. Issue addMarker CKEditor calls for
// each span
// XXX This is brittle and assumes how highlight.js generates html
// (blanks, which characters escapes, etc), a better approach
// would be to use highlight.js beta api TreeTokenizer?
// Collect all the text nodes to pass to the highlighter Text is
// direct children of the codeBlock
let text = "";
for (let i = 0; i < codeBlock.childCount; ++i) {
let child = codeBlock.getChild(i);
// We only expect text and br elements here
if (child.is("$text")) {
dbg("child text " + child.data);
text += child.data;
} else if (child.is("element") && child.name == "softBreak") {
dbg("softBreak");
text += "\n";
} else {
warn("Unkown child " + JSON.stringify(child.toJSON()));
}
}
let highlightRes;
if (mimeType === mime_types.MIME_TYPE_AUTO) {
highlightRes = hljs.highlightAuto(text);
} else {
highlightRes = hljs.highlight(text, { language: highlightJsLanguage });
}
dbg("text\n" + text);
dbg("html\n" + highlightRes.value);
let iHtml = 0;
let html = highlightRes.value;
let spanStack = [];
let iChild = -1;
let childText = "";
let child = null;
let iChildText = 0;
while (iHtml < html.length) {
// Advance the text index and fetch a new child if necessary
if (iChildText >= childText.length) {
iChild++;
if (iChild < codeBlock.childCount) {
dbg("Fetching child " + iChild);
child = codeBlock.getChild(iChild);
if (child.is("$text")) {
dbg("child text " + child.data);
childText = child.data;
iChildText = 0;
} else if (child.is("element", "softBreak")) {
dbg("softBreak");
iChildText = 0;
childText = "\n";
} else {
warn("child unknown!!!");
}
} else {
// Don't bail if beyond the last children, since there's
// still html text, it must be a closing span tag that
// needs to be dealt with below
childText = "";
}
}
// This parsing is made slightly simpler and faster by only
// expecting <span> and </span> tags in the highlighted html
if (html[iHtml] == "<" && html[iHtml + 1] != "/") {
// new span, note they can be nested eg C preprocessor lines
// are inside a hljs-meta span, hljs-title function names
// inside a hljs-function span, etc
let iStartQuot = html.indexOf('"', iHtml + 1);
let iEndQuot = html.indexOf('"', iStartQuot + 1);
let className = html.slice(iStartQuot + 1, iEndQuot);
// XXX highlight js uses scope for Python "title function_",
// etc for now just use the first style only
// See https://highlightjs.readthedocs.io/en/latest/css-classes-reference.html#a-note-on-scopes-with-sub-scopes
let iBlank = className.indexOf(" ");
if (iBlank > 0) {
className = className.slice(0, iBlank);
}
dbg("Found span start " + className);
iHtml = html.indexOf(">", iHtml) + 1;
// push the span
let posStart = writer.createPositionAt(codeBlock, (child?.startOffset ?? 0) + iChildText);
spanStack.push({ className: className, posStart: posStart });
} else if (html[iHtml] == "<" && html[iHtml + 1] == "/") {
// Done with this span, pop the span and mark the range
iHtml = html.indexOf(">", iHtml + 1) + 1;
let stackTop = spanStack.pop();
let posStart = stackTop?.posStart;
let className = stackTop?.className;
let posEnd = writer.createPositionAt(codeBlock, (child?.startOffset ?? 0) + iChildText);
let range = writer.createRange(posStart, posEnd);
let markerName = "hljs:" + className + ":" + markerCounter;
// Use an incrementing number for the uniqueId, random of
// 10000000 is known to cause collisions with a few
// codeblocks of 10s of lines on real notes (each line is
// one or more marker).
// Wrap-around for good measure so all numbers are positive
// XXX Another option is to catch the exception and retry or
// go through the markers and get the largest + 1
markerCounter = (markerCounter + 1) & 0xffffff;
dbg("Found span end " + className);
dbg("Adding marker " + markerName + ": " + JSON.stringify(range.toJSON()));
writer.addMarker(markerName, { range: range, usingOperation: false });
} else {
// Text, we should also have text in the children
assert(iChild < codeBlock.childCount && iChildText < childText.length, "Found text in html with no corresponding child text!!!!");
if (html[iHtml] == "&") {
// highlight.js only encodes
// .replace(/&/g, '&amp;')
// .replace(/</g, '&lt;')
// .replace(/>/g, '&gt;')
// .replace(/"/g, '&quot;')
// .replace(/'/g, '&#x27;');
// see https://github.com/highlightjs/highlight.js/blob/7addd66c19036eccd7c602af61f1ed84d215c77d/src/lib/utils.js#L5
let iAmpEnd = html.indexOf(";", iHtml);
dbg(html.slice(iHtml, iAmpEnd));
iHtml = iAmpEnd + 1;
} else {
// regular text
dbg(html[iHtml]);
iHtml++;
}
iChildText++;
}
}
}

View File

@ -1,6 +1,6 @@
import { t } from "../../services/i18n.js";
import libraryLoader from "../../services/library_loader.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import noteAutocompleteService, { type Suggestion } from "../../services/note_autocomplete.js";
import mimeTypesService from "../../services/mime_types.js";
import utils, { hasTouchBar } from "../../services/utils.js";
import keyboardActionService from "../../services/keyboard_actions.js";
@ -10,7 +10,6 @@ import AbstractTextTypeWidget from "./abstract_text_type_widget.js";
import link from "../../services/link.js";
import appContext, { type CommandListenerData, type EventData } from "../../components/app_context.js";
import dialogService from "../../services/dialog.js";
import { initSyntaxHighlighting } from "./ckeditor/syntax_highlight.js";
import options from "../../services/options.js";
import toast from "../../services/toast.js";
import { normalizeMimeTypeForCKEditor } from "../../services/mime_type_definitions.js";
@ -18,25 +17,25 @@ import { buildSelectedBackgroundColor } from "../../components/touch_bar.js";
import { buildConfig, buildToolbarConfig } from "./ckeditor/config.js";
import type FNote from "../../entities/fnote.js";
import { getMermaidConfig } from "../../services/mermaid.js";
import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig } from "@triliumnext/ckeditor5";
import "@triliumnext/ckeditor5/index.css";
const ENABLE_INSPECTOR = false;
const mentionSetup: MentionConfig = {
feeds: [
{
marker: "@",
feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
itemRenderer: (item) => {
const itemElement = document.createElement("button");
const mentionSetup: MentionFeed[] = [
{
marker: "@",
feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
itemRenderer: (item) => {
const itemElement = document.createElement("button");
itemElement.innerHTML = `${item.highlightedNotePathTitle} `;
itemElement.innerHTML = `${(item as Suggestion).highlightedNotePathTitle} `;
return itemElement;
},
minimumCharacters: 0
}
]
};
return itemElement;
},
minimumCharacters: 0
}
];
const TPL = /*html*/`
<div class="note-detail-editable-text note-detail-printable">
@ -127,7 +126,7 @@ function buildListOfLanguages() {
export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
private contentLanguage?: string | null;
private watchdog!: CKWatchdog;
private watchdog!: EditorWatchdog<ClassicEditor | PopupEditor>;
private $editor!: JQuery<HTMLElement>;
@ -149,16 +148,15 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
async initEditor() {
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
const isClassicEditor = utils.isMobile() || options.get("textNoteEditorType") === "ckeditor-classic";
const editorClass = isClassicEditor ? CKEditor.DecoupledEditor : CKEditor.BalloonEditor;
const editorClass = isClassicEditor ? ClassicEditor : PopupEditor;
// CKEditor since version 12 needs the element to be visible before initialization. At the same time,
// we want to avoid flicker - i.e., show editor only once everything is ready. That's why we have separate
// display of $widget in both branches.
this.$widget.show();
this.watchdog = new CKEditor.EditorWatchdog(editorClass, {
const config: WatchdogConfig = {
// An average number of milliseconds between the last editor errors (defaults to 5000).
// When the period of time between errors is lower than that and the crashNumberLimit
// is also reached, the watchdog changes its state to crashedPermanently, and it stops
@ -173,7 +171,8 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
// A minimum number of milliseconds between saving the editor data internally (defaults to 5000).
// Note that for large documents, this might impact the editor performance.
saveInterval: 5000
});
};
this.watchdog = isClassicEditor ? new EditorWatchdog(ClassicEditor, config) : new EditorWatchdog(PopupEditor, config);
this.watchdog.on("stateChange", () => {
const currentState = this.watchdog.state;
@ -189,7 +188,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
if (currentState === "crashedPermanently") {
dialogService.info(`Editing component keeps crashing. Please try restarting Trilium. If problem persists, consider creating a bug report.`);
this.watchdog.editor.enableReadOnlyMode("crashed-editor");
this.watchdog.editor?.enableReadOnlyMode("crashed-editor");
}
});
@ -205,11 +204,14 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
styles: true,
classes: true,
attributes: true
}
},
licenseKey: "GPL"
};
const contentLanguage = this.note?.getLabelValue("language");
if (contentLanguage) {
// TODO: Wrong type?
//@ts-ignore
finalConfig.language = {
ui: (typeof finalConfig.language === "string" ? finalConfig.language : "en"),
content: contentLanguage
@ -219,10 +221,11 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
this.contentLanguage = null;
}
//@ts-ignore
const editor = await editorClass.create(elementOrData, finalConfig);
const notificationsPlugin = editor.plugins.get("Notification");
notificationsPlugin.on("show:warning", (evt: CKEvent, data: PluginEventData) => {
notificationsPlugin.on("show:warning", (evt, data) => {
const title = data.title;
const message = data.message.message;
@ -235,8 +238,6 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
evt.stop();
});
await initSyntaxHighlighting(editor);
if (isClassicEditor) {
let $classicToolbarWidget;
if (!utils.isMobile()) {
@ -248,7 +249,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
$classicToolbarWidget.empty();
if ($classicToolbarWidget.length) {
$classicToolbarWidget[0].appendChild(editor.ui.view.toolbar.element);
const toolbarView = (editor as ClassicEditor).ui.view.toolbar;
if (toolbarView.element) {
$classicToolbarWidget[0].appendChild(toolbarView.element);
}
}
if (utils.isMobile()) {
@ -256,17 +260,18 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
// Reposition all dropdowns to point upwards instead of downwards.
// See https://ckeditor.com/docs/ckeditor5/latest/examples/framework/bottom-toolbar-editor.html for more info.
const toolbarView = editor.ui.view.toolbar;
const toolbarView = (editor as ClassicEditor).ui.view.toolbar;
for (const item of toolbarView.items) {
if (!("panelView" in item)) {
continue;
}
item.on("change:isOpen", () => {
if ( !item.isOpen ) {
if (!("isOpen" in item) || !item.isOpen ) {
return;
}
// @ts-ignore
item.panelView.position = item.panelView.position.replace("s", "n");
});
}
@ -276,15 +281,14 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
editor.model.document.on("change:data", () => this.spacedUpdate.scheduleUpdate());
if (glob.isDev && ENABLE_INSPECTOR) {
// TODO: Check if this still works.
await import(/* webpackIgnore: true */ "../../../libraries/ckeditor/inspector.js");
const CKEditorInspector = (await import("@ckeditor/ckeditor5-inspector")).default;
CKEditorInspector.attach(editor);
}
// Touch bar integration
if (hasTouchBar) {
for (const event of [ "bold", "italic", "underline", "paragraph", "heading" ]) {
editor.commands.get(event).on("change", () => this.triggerCommand("refreshTouchBar"));
editor.commands.get(event)?.on("change", () => this.triggerCommand("refreshTouchBar"));
}
}
@ -297,6 +301,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
async createEditor() {
await this.watchdog.create(this.$editor[0], {
placeholder: t("editable_text.placeholder"),
//@ts-ignore TODO: FIX TYPES
mention: mentionSetup,
codeBlock: {
languages: buildListOfLanguages()
@ -324,13 +329,13 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
if (this.contentLanguage !== newContentLanguage) {
await this.reinitialize(data);
} else {
this.watchdog.editor.setData(data);
this.watchdog.editor?.setData(data);
}
});
}
getData() {
const content = this.watchdog.editor.getData();
const content = this.watchdog.editor?.getData() ?? "";
// if content is only tags/whitespace (typically <p>&nbsp;</p>), then just make it empty,
// this is important when setting a new note to code
@ -344,11 +349,14 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
scrollToEnd() {
this.watchdog?.editor.model.change((writer) => {
writer.setSelection(writer.createPositionAt(this.watchdog?.editor.model.document.getRoot(), "end"));
this.watchdog?.editor?.model.change((writer) => {
const rootItem = this.watchdog?.editor?.model.document.getRoot();
if (rootItem) {
writer.setSelection(writer.createPositionAt(rootItem, "end"));
}
});
this.watchdog?.editor.editing.view.focus();
this.watchdog?.editor?.editing.view.focus();
}
show() { }
@ -360,7 +368,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
cleanup() {
if (this.watchdog?.editor) {
this.spacedUpdate.allowUpdateWithoutChange(() => {
this.watchdog.editor.setData("");
this.watchdog.editor?.setData("");
});
}
}
@ -375,18 +383,22 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
async addLinkToEditor(linkHref: string, linkTitle: string) {
await this.initialized;
this.watchdog.editor.model.change((writer) => {
const insertPosition = this.watchdog.editor.model.document.selection.getFirstPosition();
writer.insertText(linkTitle, { linkHref: linkHref }, insertPosition);
this.watchdog.editor?.model.change((writer) => {
const insertPosition = this.watchdog.editor?.model.document.selection.getFirstPosition();
if (insertPosition) {
writer.insertText(linkTitle, { linkHref: linkHref }, insertPosition);
}
});
}
async addTextToEditor(text: string) {
await this.initialized;
this.watchdog.editor.model.change((writer) => {
const insertPosition = this.watchdog.editor.model.document.selection.getLastPosition();
writer.insertText(text, insertPosition);
this.watchdog.editor?.model.change((writer) => {
const insertPosition = this.watchdog.editor?.model.document.selection.getLastPosition();
if (insertPosition) {
writer.insertText(text, insertPosition);
}
});
}
@ -403,23 +415,23 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
if (linkTitle) {
if (this.hasSelection()) {
this.watchdog.editor.execute("link", externalLink ? `${notePath}` : `#${notePath}`);
this.watchdog.editor?.execute("link", externalLink ? `${notePath}` : `#${notePath}`);
} else {
await this.addLinkToEditor(externalLink ? `${notePath}` : `#${notePath}`, linkTitle);
}
} else {
this.watchdog.editor.execute("referenceLink", { href: "#" + notePath });
this.watchdog.editor?.execute("referenceLink", { href: "#" + notePath });
}
this.watchdog.editor.editing.view.focus();
this.watchdog.editor?.editing.view.focus();
}
// returns true if user selected some text, false if there's no selection
hasSelection() {
const model = this.watchdog.editor.model;
const selection = model.document.selection;
const model = this.watchdog.editor?.model;
const selection = model?.document.selection;
return !selection.isCollapsed;
return !selection?.isCollapsed;
}
async executeWithTextEditorEvent({ callback, resolve, ntxId }: EventData<"executeWithTextEditor">) {
@ -429,11 +441,15 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
await this.initialized;
if (callback) {
callback(this.watchdog.editor);
if (!this.watchdog.editor) {
return;
}
resolve(this.watchdog.editor);
if (callback) {
callback(this.watchdog.editor as CKTextEditor);
}
resolve(this.watchdog.editor as CKTextEditor);
}
addLinkToTextCommand() {
@ -443,11 +459,15 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
getSelectedText() {
const range = this.watchdog.editor.model.document.selection.getFirstRange();
const range = this.watchdog.editor?.model.document.selection.getFirstRange();
let text = "";
if (!range) {
return text;
}
for (const item of range.getItems()) {
if (item.data) {
if ("data" in item && item.data) {
text += item.data;
}
}
@ -458,12 +478,12 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
async followLinkUnderCursorCommand() {
await this.initialized;
const selection = this.watchdog.editor.model.document.selection;
const selectedElement = selection.getSelectedElement();
const selection = this.watchdog.editor?.model.document.selection;
const selectedElement = selection?.getSelectedElement();
if (selectedElement?.name === "reference") {
// reference link
const notePath = selectedElement.getAttribute("notePath");
const notePath = selectedElement.getAttribute("notePath") as string | undefined;
if (notePath) {
await appContext.tabManager.getActiveContext()?.setNote(notePath);
@ -471,11 +491,11 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
}
if (!selection.hasAttribute("linkHref")) {
if (!selection?.hasAttribute("linkHref")) {
return;
}
const selectedLinkUrl = selection.getAttribute("linkHref");
const selectedLinkUrl = selection.getAttribute("linkHref") as string;
const notePath = link.getNotePathFromUrl(selectedLinkUrl);
if (notePath) {
@ -490,10 +510,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
addIncludeNote(noteId: string, boxSize?: string) {
this.watchdog.editor.model.change((writer) => {
this.watchdog.editor?.model.change((writer) => {
// Insert <includeNote>*</includeNote> at the current selection position
// in a way that will result in creating a valid model structure
this.watchdog.editor.model.insertContent(
this.watchdog.editor?.model.insertContent(
writer.createElement("includeNote", {
noteId: noteId,
boxSize: boxSize
@ -504,7 +524,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
async addImage(noteId: string) {
const note = await froca.getNote(noteId);
if (!note) {
if (!note || !this.watchdog.editor) {
return;
}
@ -512,7 +532,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
const encodedTitle = encodeURIComponent(note.title);
const src = `api/images/${note.noteId}/${encodedTitle}`;
this.watchdog.editor.execute("insertImage", { source: src });
this.watchdog.editor?.execute("insertImage", { source: src });
});
}
@ -544,12 +564,12 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
this.watchdog.destroy();
await this.createEditor();
this.watchdog.editor.setData(data);
this.watchdog.editor?.setData(data);
}
async onLanguageChanged() {
const data = this.watchdog.editor.getData();
await this.reinitialize(data);
const data = this.watchdog.editor?.getData();
await this.reinitialize(data ?? "");
}
buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
@ -557,20 +577,24 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
const { TouchBarSegmentedControl, TouchBarGroup, TouchBarButton } = TouchBar;
const { editor } = this.watchdog;
if (!editor) {
return;
}
const commandButton = (icon: string, command: string) => new TouchBarButton({
icon: buildIcon(icon),
click: () => editor.execute(command),
backgroundColor: buildSelectedBackgroundColor(editor.commands.get(command).value as boolean)
backgroundColor: buildSelectedBackgroundColor(editor.commands.get(command)?.value as boolean)
});
let headingSelectedIndex = undefined;
const headingCommand = editor.commands.get("heading");
const paragraphCommand = editor.commands.get("paragraph");
if (paragraphCommand.value) {
if (paragraphCommand?.value) {
headingSelectedIndex = 0;
} else if (headingCommand.value === "heading2") {
} else if (headingCommand?.value === "heading2") {
headingSelectedIndex = 1;
} else if (headingCommand.value === "heading3") {
} else if (headingCommand?.value === "heading3") {
headingSelectedIndex = 2;
}
@ -581,7 +605,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
{ label: "H2" },
{ label: "H3" }
],
change(selectedIndex, isSelected) {
change(selectedIndex: number, isSelected: boolean) {
switch (selectedIndex) {
case 0:
editor.execute("paragraph")

View File

@ -100,7 +100,7 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
// we load CKEditor also for read only notes because they contain content styles required for correct rendering of even read only notes
// we could load just ckeditor-content.css but that causes CSS conflicts when both build CSS and this content CSS is loaded at the same time
// (see https://github.com/zadam/trilium/issues/1590 for example of such conflict)
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
await import("@triliumnext/ckeditor5");
this.onLanguageChanged();

View File

@ -34,6 +34,9 @@
"src/**/*.ts"
],
"references": [
{
"path": "../../packages/ckeditor5/tsconfig.lib.json"
},
{
"path": "../../packages/commons/tsconfig.lib.json"
}

View File

@ -3,6 +3,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../../packages/ckeditor5"
},
{
"path": "../../packages/commons"
},

View File

@ -1,80 +1,116 @@
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { composePlugins, withNx, withWeb } = require('@nx/webpack');
const { join } = require('path');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
output: {
path: join(__dirname, 'dist'),
},
devServer: {
port: 4200,
client: {
overlay: {
errors: true,
warnings: false,
runtimeErrors: true
}
}
},
plugins: [
new NxAppWebpackPlugin({
tsConfig: './tsconfig.app.json',
compiler: 'swc',
main: "./src/index.ts",
additionalEntryPoints: [
{
entryName: "desktop",
entryPath: "./src/desktop.ts"
},
{
entryName: "mobile",
entryPath: "./src/mobile.ts"
},
{
entryName: "login",
entryPath: "./src/login.ts"
},
{
entryName: "setup",
entryPath: "./src/setup.ts"
},
{
entryName: "share",
entryPath: "./src/share.ts"
},
{
// TriliumNextTODO: integrate set_password into setup entry point/view
entryName: "set_password",
entryPath: "./src/set_password.ts"
}
],
externalDependencies: [
"electron"
],
baseHref: '/',
assets: [
"./src/assets",
"./src/stylesheets",
"./src/libraries",
"./src/fonts",
"./src/translations"
],
styles: [],
stylePreprocessorOptions: {
sassOptions: {
quietDeps: true
}
module.exports = composePlugins(
withNx({
tsConfig: join(__dirname, './tsconfig.app.json'),
compiler: "tsc",
main: join(__dirname, "./src/index.ts"),
additionalEntryPoints: [
{
entryName: "desktop",
entryPath: join(__dirname, "./src/desktop.ts")
},
outputHashing: false,
optimization: process.env['NODE_ENV'] === 'production',
})
],
resolve: {
fallback: {
path: false,
fs: false,
util: false
}
}
};
{
entryName: "mobile",
entryPath: join(__dirname, "./src/mobile.ts")
},
{
entryName: "login",
entryPath: join(__dirname, "./src/login.ts")
},
{
entryName: "setup",
entryPath: join(__dirname, "./src/setup.ts")
},
{
entryName: "share",
entryPath: join(__dirname, "./src/share.ts")
},
{
// TriliumNextTODO: integrate set_password into setup entry point/view
entryName: "set_password",
entryPath: join(__dirname, "./src/set_password.ts")
}
],
externalDependencies: [
"electron"
],
baseHref: '/',
outputHashing: false,
optimization: process.env['NODE_ENV'] === 'production'
}),
withWeb({
styles: [],
stylePreprocessorOptions: {
sassOptions: {
quietDeps: true
}
},
}),
(config) => {
config.output = {
path: join(__dirname, 'dist')
};
config.devServer = {
port: 4200,
client: {
overlay: {
errors: true,
warnings: false,
runtimeErrors: true
}
}
}
config.resolve.fallback = {
path: false,
fs: false,
util: false
};
const assets = [ "assets", "stylesheets", "libraries", "fonts", "translations" ]
config.plugins.push(new CopyPlugin({
patterns: assets.map((asset) => ({
from: join(__dirname, "src", asset),
to: asset
}))
}));
inlineSvg(config);
externalJson(config);
return config;
}
);
function inlineSvg(config) {
if (!config.module?.rules) {
return;
}
// Alter Nx's asset rule to avoid inlining SVG if they have ?raw prepended.
const existingRule = config.module.rules.find((r) => r.test.toString() === /\.svg$/.toString());
existingRule.resourceQuery = { not: [/raw/] };
// Add a rule for prepending ?raw SVGs.
config.module.rules.push({
resourceQuery: /raw/,
type: 'asset/source',
});
}
function externalJson(config) {
if (!config.module?.rules) {
return;
}
// Add a rule for prepending ?external.
config.module.rules.push({
resourceQuery: /external/,
type: 'asset/resource',
});
}

View File

@ -0,0 +1 @@
TRILIUM_PORT=37743

View File

@ -18,16 +18,15 @@
"@types/electron-squirrel-startup": "1.0.2",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "13.0.0",
"electron": "35.2.2",
"@electron/rebuild": "4.0.1",
"@electron-forge/cli": "7.8.0",
"@electron-forge/maker-deb": "7.8.0",
"@electron-forge/maker-dmg": "7.8.0",
"@electron-forge/maker-flatpak": "7.8.0",
"@electron-forge/maker-rpm": "7.8.0",
"@electron-forge/maker-squirrel": "7.8.0",
"@electron-forge/maker-zip": "7.8.0",
"@electron-forge/plugin-auto-unpack-natives": "7.8.0",
"electron": "36.2.0",
"@electron-forge/cli": "7.8.1",
"@electron-forge/maker-deb": "7.8.1",
"@electron-forge/maker-dmg": "7.8.1",
"@electron-forge/maker-flatpak": "7.8.1",
"@electron-forge/maker-rpm": "7.8.1",
"@electron-forge/maker-squirrel": "7.8.1",
"@electron-forge/maker-zip": "7.8.1",
"@electron-forge/plugin-auto-unpack-natives": "7.8.1",
"prebuild-install": "^7.1.1"
},
"config": {
@ -55,12 +54,10 @@
"cache": true,
"configurations": {
"default": {
"command": "cross-env DEBUG=* tsx scripts/rebuild.mts",
"cwd": "{projectRoot}"
"command": "cross-env DEBUG=* tsx scripts/electron-rebuild.mts {projectRoot}/dist"
},
"nixos": {
"command": "electron-rebuild -f -v $(nix-shell -p electron_35 --run \"electron --version\") dist/main.js -m dist",
"cwd": "{projectRoot}"
"command": "cross-env DEBUG=* tsx scripts/electron-rebuild.mts {projectRoot}/dist $(nix-shell -p electron_33 --run \"electron --version\")"
}
}
},
@ -76,7 +73,25 @@
"cwd": "{projectRoot}/dist"
},
"nixos": {
"command": "nix-shell -p electron_35 --run \"electron {projectRoot}/dist/main.js\"",
"command": "nix-shell -p electron_33 --run \"electron {projectRoot}/dist/main.js\"",
"cwd": ".",
"forwardAllArgs": false
}
}
},
"serve-nodir": {
"executor": "nx:run-commands",
"dependsOn": [
"rebuild-deps"
],
"defaultConfiguration": "default",
"configurations": {
"default": {
"command": "electron .",
"cwd": "{projectRoot}/dist"
},
"nixos": {
"command": "nix-shell -p electron_33 --run \"electron {projectRoot}/dist/main.js\"",
"cwd": ".",
"forwardAllArgs": false
}

View File

@ -1,35 +0,0 @@
/**
* @module
*
* This script is used internally by the `rebuild-deps` target of the `desktop`. Normally we could use
* `electron-rebuild` CLI directly, but it would rebuild the monorepo-level dependencies and breaks
* the server build (and it doesn't expose a CLI option to override this).
*/
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { rebuild } from "@electron/rebuild"
import { readFileSync } from "fs";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const rootDir = join(scriptDir, "..");
function getElectronVersion() {
const packageJsonPath = join(rootDir, "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
return packageJson.devDependencies.electron;
}
function main() {
const distDir = join(rootDir, "dist");
rebuild({
// We force the project root path to avoid electron-rebuild from rebuilding the monorepo-level dependency and breaking the server.
projectRootPath: distDir,
buildPath: distDir,
force: true,
electronVersion: getElectronVersion(),
});
}
main();

View File

@ -8,6 +8,14 @@ module.exports = {
output: {
path: outputDir,
},
module: {
rules: [
{
test: /\.css$/i,
type: "asset/source"
}
]
},
target: [ "node" ],
plugins: [
new NxAppWebpackPlugin({

View File

@ -4,32 +4,34 @@
"private": true,
"description": "Desktop version of Trilium which imports the demo database (presented to new users at start-up) or the user guide and other documentation and saves the modifications for committing.",
"devDependencies": {
"@electron/rebuild": "4.0.1",
"@triliumnext/client": "workspace:*",
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "13.0.0",
"electron": "35.2.2",
"electron": "36.2.0",
"fs-extra": "11.3.0"
},
"nx": {
"name": "edit-docs",
"implicitDependencies": [
"server"
],
"targets": {
"rebuild-deps": {
"executor": "nx:run-commands",
"dependsOn": [ "build" ],
"dependsOn": [
"build"
],
"defaultConfiguration": "default",
"cache": true,
"configurations": {
"default": {
"command": "cross-env DEBUG=* tsx scripts/rebuild.mts",
"cwd": "{projectRoot}"
"command": "cross-env DEBUG=* tsx scripts/electron-rebuild.mts {projectRoot}/dist"
},
"nixos": {
"command": "electron-rebuild -f -v $(nix-shell -p electron_35 --run \"electron --version\") dist/main.js -m dist",
"cwd": "{projectRoot}"
"command": "cross-env DEBUG=* tsx scripts/electron-rebuild.mts {projectRoot}/dist $(nix-shell -p electron_33 --run \"electron --version\")"
}
}
}
},
"serve": {
"executor": "nx:run-commands",

View File

@ -1,37 +0,0 @@
/**
* @module
*
* This script is used internally by the `rebuild-deps` target of the `desktop`. Normally we could use
* `electron-rebuild` CLI directly, but it would rebuild the monorepo-level dependencies and breaks
* the server build (and it doesn't expose a CLI option to override this).
*/
// TODO: Deduplicate with apps/desktop/scripts/rebuild.ts.
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { rebuild } from "@electron/rebuild"
import { readFileSync } from "fs";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const rootDir = join(scriptDir, "..");
function getElectronVersion() {
const packageJsonPath = join(rootDir, "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
return packageJson.devDependencies.electron;
}
function main() {
const distDir = join(rootDir, "dist");
rebuild({
// We force the project root path to avoid electron-rebuild from rebuilding the monorepo-level dependency and breaking the server.
projectRootPath: distDir,
buildPath: distDir,
force: true,
electronVersion: getElectronVersion(),
});
}
main();

View File

@ -8,6 +8,14 @@ module.exports = {
output: {
path: join(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.css$/i,
type: "asset/source"
}
]
},
plugins: [
new NxAppWebpackPlugin({
target: 'node',

View File

@ -7,7 +7,14 @@
"implicitDependencies": [
"client",
"server"
]
],
"targets": {
"e2e": {
"dependsOn": [
"server:build"
]
}
}
},
"devDependencies": {
"dotenv": "16.5.0"

View File

@ -55,6 +55,8 @@ test("Displays math popup", async ({ page, context }) => {
await app.goto();
await app.goToNoteInNewTab("Empty text");
const noteContent = app.currentNoteSplit.locator(".note-detail-editable-text-editor");
await expect(noteContent.locator("p")).toBeVisible();
await noteContent.focus();
await noteContent.fill("Hello world");
await noteContent.press("ControlOrMeta+M");

View File

@ -4,7 +4,8 @@ FROM node:22.15.0-bullseye-slim AS builder
# Install native dependencies since we might be building cross-platform.
WORKDIR /usr/src/app/build
COPY ./dist/package.json ./dist/pnpm-lock.yaml ./docker/pnpm-workspace.yaml /usr/src/app/
RUN pnpm install --frozen-lockfile --prod && pnpm rebuild
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.15.0-bullseye-slim
# Install only runtime dependencies

View File

@ -4,7 +4,8 @@ FROM node:22.15.0-alpine AS builder
# Install native dependencies since we might be building cross-platform.
WORKDIR /usr/src/app
COPY ./dist/package.json ./dist/pnpm-lock.yaml ./docker/pnpm-workspace.yaml /usr/src/app/
RUN pnpm install --frozen-lockfile --prod && pnpm rebuild
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:22.15.0-alpine
# Install runtime dependencies

View File

@ -4,7 +4,7 @@
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
"private": true,
"dependencies": {
"better-sqlite3": "11.9.1",
"better-sqlite3": "11.10.0",
"jquery.fancytree": "2.38.5",
"jquery-hotkeys": "0.2.2",
"@highlightjs/cdn-assets": "11.11.1"
@ -14,7 +14,6 @@
"@excalidraw/excalidraw": "0.18.0",
"@types/archiver": "6.0.3",
"@types/better-sqlite3": "7.6.13",
"@types/cheerio": "0.22.35",
"@types/cls-hooked": "4.3.9",
"@types/compression": "1.7.5",
"@types/cookie-parser": "1.4.8",
@ -31,7 +30,7 @@
"@types/mime-types": "2.1.4",
"@types/multer": "1.4.12",
"@types/safe-compare": "1.1.2",
"@types/sanitize-html": "2.15.0",
"@types/sanitize-html": "2.16.0",
"@types/sax": "1.2.7",
"@types/serve-favicon": "2.5.7",
"@types/serve-static": "1.15.7",
@ -50,7 +49,7 @@
"jquery": "3.7.1",
"katex": "0.16.22",
"normalize.css": "8.0.1",
"@anthropic-ai/sdk": "0.40.1",
"@anthropic-ai/sdk": "0.50.3",
"@braintree/sanitize-url": "7.1.1",
"@triliumnext/commons": "workspace:*",
"@triliumnext/express-partial-content": "workspace:*",
@ -70,7 +69,7 @@
"debounce": "2.2.0",
"debug": "4.4.0",
"ejs": "3.1.10",
"electron": "35.2.2",
"electron": "36.2.0",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
@ -85,7 +84,7 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"i18next": "25.0.2",
"i18next": "25.1.2",
"i18next-fs-backend": "2.6.0",
"image-type": "5.2.0",
"ini": "5.0.0",
@ -99,7 +98,7 @@
"multer": "1.4.5-lts.2",
"normalize-strings": "1.1.1",
"ollama": "0.5.15",
"openai": "4.97.0",
"openai": "4.98.0",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",
"sanitize-filename": "1.6.3",
@ -117,7 +116,7 @@
"tmp": "0.2.3",
"turndown": "7.2.0",
"unescape": "1.0.1",
"webpack": "5.99.7",
"webpack": "5.99.8",
"ws": "8.18.2",
"xml2js": "0.6.2",
"yauzl": "3.2.0",
@ -125,10 +124,13 @@
},
"nx": {
"name": "server",
"implicitDependencies": [
"client"
],
"targets": {
"build": {
"dependsOn": [
"^build",
"client:build"
]
},
"serve": {
"executor": "@nx/js:node",
"defaultConfiguration": "development",

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,29 @@
<p>Bookmarks allows creating <a href="#root/_help_QEAPj01N5f7w">links</a> to
a certain part of a note, such as referencing a particular heading.</p>
<p>Technically, bookmarks are HTML anchors.</p>
<p>This feature was introduced in TriliumNext 0.94.0.</p>
<h2>Interaction</h2>
<ul>
<li>To create a bookmark:
<ul>
<li>Place the cursor at the desired position where to place the bookmark.</li>
<li>Look for the
<img src="Bookmarks_plus.png" width="15" height="16">button in the&nbsp;<a class="reference-link" href="#root/_help_nRhnJkTT8cPs">Formatting toolbar</a>,
and then press the
<img src="1_Bookmarks_plus.png" width="12" height="15">button.</li>
</ul>
</li>
<li>To place a link to a bookmark:
<ul>
<li>Place the cursor at the desired position of the link.</li>
<li>From the <a href="#root/_help_QEAPj01N5f7w">link</a> pane, select the <em>Bookmarks</em> section
and select the desired bookmark.</li>
</ul>
</li>
</ul>
<h2>Limitations</h2>
<ul>
<li>Currently it's not possible to create a link to a bookmark from a different
note. This functionality will be added after the internal links feature
is enhanced to support bookmarks.</li>
</ul>

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

View File

@ -2,59 +2,76 @@
<img src="4_Insert buttons_image.png" width="34" height="16">button in the&nbsp;<a class="reference-link" href="#root/_help_nRhnJkTT8cPs">Formatting toolbar</a>&nbsp;to
reveal special inserable items and blocks such as symbols, Math expressions
and separators.</p>
<h2>Symbols</h2>
<figure class="image image-style-align-right">
<img style="aspect-ratio:346/322;" src="1_Insert buttons_image.png" width="346"
height="322">
<h2>Bookmarks</h2>
<p>See the dedicated&nbsp;<a class="reference-link" href="#root/_help_oSuaNgyyKnhu">Bookmarks</a>&nbsp;section.</p>
<h2>Emoji</h2>
<figure class="image image-style-align-right image_resized" style="width:42.4%;">
<img style="aspect-ratio:366/410;" src="Insert buttons_plus.png" width="366"
height="410">
</figure>
<p>Pressing the
<img src="7_Insert buttons_image.png" width="18" height="15">button will reveal a popup window displaying a list of characters that
are generally more difficult to insert directly from the keyboard, such
as a subset of emojis, quotation characters, etc.</p>
<p>Interaction:</p>
<ul>
<li>Click on a character to insert it at the current cursor position.</li>
<li>The window can be dragged around by the top bar where the title is, to
avoid it getting in the way of the text.</li>
<li>Click on the <em>Category</em> selector to filter the characters.</li>
</ul>
<h2>Math equations</h2>
<p>See the dedicated&nbsp;<a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>&nbsp;page.</p>
<h2>Mermaid diagram</h2>
<p>Press the
<img src="2_Insert buttons_image.png" width="16" height="17">button to create an inline Mermaid diagram.</p>
<p>This feature is quite similar to the&nbsp;<a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;note
types and is meant as an alternative to it for simple diagrams. For more
complex diagrams, use the&nbsp;<a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;feature
for a dedicated Mermaid note.</p>
<figure class="image">
<img style="aspect-ratio:1174/358;" src="6_Insert buttons_image.png" width="1174"
height="358">
</figure>
<h2>Horizontal ruler</h2>
<p>This feature will display a horizontal line, generally useful to separate
different sections of the text. To do so, press the
<img src="5_Insert buttons_image.png"
width="18" height="16">button in the&nbsp;<a class="reference-link" href="#root/_help_nRhnJkTT8cPs">Formatting toolbar</a>.</p>
<p>This feature allows inserting Unicode emoji characters. Simply select
a category and a desired emoji to insert it.</p>
<p>Emojis can also be searched by their English name and the skin tone can
be selected via a combo box to the right.</p>
<p>There is also the possibility of inserting emojis directly by typing <code>:</code> followed
by a name of an emoji, triggering the display of a list of emojis. Simply
use the arrow keys to select one and press <kbd>Enter</kbd> to insert it.</p>
<img
src="3_Insert buttons_image.png" width="502" height="95">
<p>Alternatively, it's possible to insert a horizontal ruler by typing <code>---</code>.</p>
<h2>Page break</h2>
src="1_Insert buttons_plus.png" width="272" height="187">
<h2>Symbols</h2>
<figure class="image image-style-align-right">
<img style="aspect-ratio:371/79;" src="8_Insert buttons_image.png" width="371"
height="79">
<img style="aspect-ratio:346/322;" src="1_Insert buttons_image.png" width="346"
height="322">
</figure>
<p>Page breaks provide a way to force the next paragraph or block (table,
image, etc.) to be displayed onto the next page when printing (either to
a real printer to <a href="#root/_help_NRnIZmSMc5sj">when exporting to PDF</a>).</p>
<p>Page breaks are marked in the editor with the words <em>Page break</em>,
but they will not actually be shown when printed.</p>
<p>Pressing the
<img src="7_Insert buttons_image.png" width="18" height="15">button will reveal a popup window displaying a list of characters that
are generally more difficult to insert directly from the keyboard, such
as a subset of emojis, quotation characters, etc.</p>
<p>Interaction:</p>
<ul>
<li>To insert a page break, press the
<img src="Insert buttons_image.png" width="20"
height="19">in the formatting toolbar.</li>
<li>To insert many page breaks at once, insert a page break first, click on
it and press <kbd>Ctrl</kbd>+<kbd>C</kbd>. Then use <kbd>Ctrl</kbd>+<kbd>V</kbd>,
to paste as many times as needed.</li>
</ul>
<li>Click on a character to insert it at the current cursor position.</li>
<li>The window can be dragged around by the top bar where the title is, to
avoid it getting in the way of the text.</li>
<li>Click on the <em>Category</em> selector to filter the characters.</li>
</ul>
<h2>Math equations</h2>
<p>See the dedicated&nbsp;<a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>&nbsp;page.</p>
<h2>Mermaid diagram</h2>
<p>Press the
<img src="2_Insert buttons_image.png" width="16" height="17">button to create an inline Mermaid diagram.</p>
<p>This feature is quite similar to the&nbsp;<a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;note
types and is meant as an alternative to it for simple diagrams. For more
complex diagrams, use the&nbsp;<a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;feature
for a dedicated Mermaid note.</p>
<figure class="image">
<img style="aspect-ratio:1174/358;" src="6_Insert buttons_image.png" width="1174"
height="358">
</figure>
<h2>Horizontal ruler</h2>
<p>This feature will display a horizontal line, generally useful to separate
different sections of the text. To do so, press the
<img src="5_Insert buttons_image.png"
width="18" height="16">button in the&nbsp;<a class="reference-link" href="#root/_help_nRhnJkTT8cPs">Formatting toolbar</a>.</p>
<img
src="3_Insert buttons_image.png" width="502" height="95">
<p>Alternatively, it's possible to insert a horizontal ruler by typing <code>---</code>.</p>
<h2>Page break</h2>
<figure class="image image-style-align-right">
<img style="aspect-ratio:371/79;" src="8_Insert buttons_image.png" width="371"
height="79">
</figure>
<p>Page breaks provide a way to force the next paragraph or block (table,
image, etc.) to be displayed onto the next page when printing (either to
a real printer to <a href="#root/_help_NRnIZmSMc5sj">when exporting to PDF</a>).</p>
<p>Page breaks are marked in the editor with the words <em>Page break</em>,
but they will not actually be shown when printed.</p>
<ul>
<li>To insert a page break, press the
<img src="Insert buttons_image.png" width="20"
height="19">in the formatting toolbar.</li>
<li>To insert many page breaks at once, insert a page break first, click on
it and press <kbd>Ctrl</kbd>+<kbd>C</kbd>. Then use <kbd>Ctrl</kbd>+<kbd>V</kbd>,
to paste as many times as needed.</li>
</ul>

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -59,6 +59,8 @@
with the text inside of it.</li>
</ul>
</li>
<li>For <a href="#root/_help_CohkqWQC1iBv">emojis</a>, type <code>:</code> followed
by an emoji name to trigger an auto-completion.</li>
</ul>
<p>If auto-formatting is not desirable, press <kbd>Ctrl</kbd> + <kbd>Z</kbd> to
revert the text to its original form.</p>

View File

@ -7,7 +7,8 @@
<img src="1_Math Equations_image.png" width="20" height="15">button from the&nbsp;<a class="reference-link" href="#root/_help_nRhnJkTT8cPs">Formatting toolbar</a>&nbsp;(generally
found under the&nbsp;<a class="reference-link" href="#root/_help_CohkqWQC1iBv">Insert buttons</a>).</p>
<p>If inserting equations frequently, using the <kbd>Ctrl</kbd>+<kbd>M</kbd> keyboard
shortcut can be more comfortable.</p>
shortcut can be more comfortable. Alternatively, type <code>$$</code> or <code>\[</code> to
trigger the popup directly.</p>
<p>There is currently no quick way to insert an equation, such as surrounding
it with <code>$</code> or pressing <kbd>Ctrl</kbd>+<kbd>M</kbd> on an already
typed-out equation.</p>

View File

@ -5,6 +5,7 @@ import express from "express";
import { getResourceDir, isDev } from "../services/utils.js";
import type serveStatic from "serve-static";
import proxy from "express-http-proxy";
import contentCss from "@triliumnext/ckeditor5/content.css";
const persistentCacheStatic = (root: string, options?: serveStatic.ServeStaticOptions<express.Response<unknown, Record<string, unknown>>>) => {
if (!isDev) {
@ -20,6 +21,8 @@ async function register(app: express.Application) {
const srcRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const resourceDir = getResourceDir();
app.use(`/${assetPath}/libraries/ckeditor/ckeditor-content.css`, (req, res) => res.contentType("text/css").send(contentCss));
if (isDev) {
const publicUrl = process.env.TRILIUM_PUBLIC_SERVER;
if (!publicUrl) {

View File

@ -1,11 +1,12 @@
import { describe, expect, it } from "vitest";
import { getStylesDirectory, readThemesFromFileSystem } from "./code_block_theme.js";
import { readThemesFromFileSystem } from "./code_block_theme.js";
import themeNames from "./code_block_theme_names.json" with { type: "json" };
import path = require("path");
describe("Code block theme", () => {
it("all themes are mapped", () => {
const themes = readThemesFromFileSystem(getStylesDirectory());
const themes = readThemesFromFileSystem(path.join(__dirname, "../../node_modules/@highlightjs/cdn-assets/styles"));
const mappedThemeNames = new Set(Object.values(themeNames));
const unmappedThemeNames = new Set<string>();

View File

@ -30,7 +30,7 @@ interface ColorTheme {
* @returns the supported themes, grouped.
*/
export function listSyntaxHighlightingThemes() {
const path = join(getResourceDir(), getStylesDirectory());
const path = getStylesDirectory();
const systemThemes = readThemesFromFileSystem(path);
return {
@ -46,11 +46,11 @@ export function listSyntaxHighlightingThemes() {
export function getStylesDirectory() {
if (isElectron && !isDev) {
return "styles";
return join(getResourceDir(), "styles");
} else if (!isDev) {
return "node_modules/@highlightjs/cdn-assets/styles";
return join(getResourceDir(), "node_modules/@highlightjs/cdn-assets/styles");
} else {
return join(__dirname, "../../node_modules/@highlightjs/cdn-assets/styles");
return join(__dirname, "../node_modules/@highlightjs/cdn-assets/styles");
}
}

View File

@ -21,6 +21,7 @@ import type AttributeMeta from "../meta/attribute_meta.js";
import type BBranch from "../../becca/entities/bbranch.js";
import type { Response } from "express";
import type { NoteMetaFile } from "../meta/note_meta.js";
import cssContent from "@triliumnext/ckeditor5/content.css";
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
@ -511,8 +512,6 @@ ${markdownContent}`;
return;
}
const cssContent = fs.readFileSync(`${getResourceDir()}/public/libraries/ckeditor/ckeditor-content.css`);
archive.append(cssContent, { name: cssMeta.dataFileName });
}

View File

@ -22,3 +22,8 @@ declare module "is-animated" {
function isAnimated(buffer: Buffer): boolean;
export default isAnimated;
}
declare module "@triliumnext/ckeditor5/content.css" {
const content: string;
export default content;
}

View File

@ -33,6 +33,9 @@
"src/**/*.spec.jsx"
],
"references": [
{
"path": "../../packages/ckeditor5/tsconfig.lib.json"
},
{
"path": "../../packages/turndown-plugin-gfm/tsconfig.lib.json"
},

View File

@ -3,6 +3,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../../packages/ckeditor5"
},
{
"path": "../../packages/turndown-plugin-gfm"
},

View File

@ -1,6 +1,6 @@
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const { join, default: path } = require('path');
const { join } = require('path');
const outputDir = join(__dirname, 'dist');
@ -48,6 +48,14 @@ module.exports = {
output: {
path: outputDir
},
module: {
rules: [
{
test: /\.css$/i,
type: "asset/source"
}
]
},
plugins: [
new NxAppWebpackPlugin({
target: 'node',

View File

@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.0.1",
"appVersion": "0.94.0",
"files": [
{
"isClone": false,
@ -69,7 +69,67 @@
],
"format": "markdown",
"dataFileName": "Project Structure.md",
"attachments": []
"attachments": [],
"dirFileName": "Project Structure",
"children": [
{
"isClone": false,
"noteId": "Jg7clqogFOyD",
"notePath": [
"jdjRLhLV3TtI",
"cxfTSHIUQtt2",
"Jg7clqogFOyD"
],
"title": "CKEditor",
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "label",
"name": "iconClass",
"value": "bx bx-package",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "BRhQZHgwaGyw",
"isInheritable": false,
"position": 20
}
],
"format": "markdown",
"dataFileName": "CKEditor.md",
"attachments": [],
"dirFileName": "CKEditor",
"children": [
{
"isClone": false,
"noteId": "BRhQZHgwaGyw",
"notePath": [
"jdjRLhLV3TtI",
"cxfTSHIUQtt2",
"Jg7clqogFOyD",
"BRhQZHgwaGyw"
],
"title": "Plugin migration guide",
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [],
"format": "markdown",
"dataFileName": "Plugin migration guide.md",
"attachments": []
}
]
}
]
},
{
"isClone": false,
@ -169,15 +229,7 @@
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "IxkDdjTogO18",
"isInheritable": false,
"position": 10
}
],
"attributes": [],
"format": "markdown",
"dataFileName": "Releasing a version.md",
"attachments": []
@ -220,6 +272,22 @@
"attachments": [],
"dirFileName": "Building and deployment",
"children": [
{
"isClone": true,
"noteId": "PXzm2t3sCdsP",
"notePath": [
"jdjRLhLV3TtI",
"ibAPHul7Efvr",
"sUqOpnrQyEC7",
"PXzm2t3sCdsP"
],
"title": "Build deliveries locally",
"prefix": null,
"dataFileName": "Build deliveries locally.clone.md",
"type": "text",
"format": "markdown",
"isExpanded": false
},
{
"isClone": false,
"noteId": "zdQzavvHDl1k",
@ -240,54 +308,6 @@
"dataFileName": "Documentation.md",
"attachments": []
},
{
"isClone": true,
"noteId": "czgXkoEYwclZ",
"notePath": [
"jdjRLhLV3TtI",
"ibAPHul7Efvr",
"sUqOpnrQyEC7",
"czgXkoEYwclZ"
],
"title": "Running a development build",
"prefix": null,
"dataFileName": "Running a development build.clone.md",
"type": "text",
"format": "markdown",
"isExpanded": false
},
{
"isClone": true,
"noteId": "PXzm2t3sCdsP",
"notePath": [
"jdjRLhLV3TtI",
"ibAPHul7Efvr",
"sUqOpnrQyEC7",
"PXzm2t3sCdsP"
],
"title": "Build deliveries locally",
"prefix": null,
"dataFileName": "Build deliveries locally.clone.md",
"type": "text",
"format": "markdown",
"isExpanded": false
},
{
"isClone": true,
"noteId": "rLWcPPQi7Eso",
"notePath": [
"jdjRLhLV3TtI",
"ibAPHul7Efvr",
"sUqOpnrQyEC7",
"rLWcPPQi7Eso"
],
"title": "Releasing a version",
"prefix": null,
"dataFileName": "Releasing a version.clone.md",
"type": "text",
"format": "markdown",
"isExpanded": false
},
{
"isClone": false,
"noteId": "oqg9OpK8xfcm",
@ -355,6 +375,38 @@
]
}
]
},
{
"isClone": true,
"noteId": "rLWcPPQi7Eso",
"notePath": [
"jdjRLhLV3TtI",
"ibAPHul7Efvr",
"sUqOpnrQyEC7",
"rLWcPPQi7Eso"
],
"title": "Releasing a version",
"prefix": null,
"dataFileName": "Releasing a version.clone.md",
"type": "text",
"format": "markdown",
"isExpanded": false
},
{
"isClone": true,
"noteId": "czgXkoEYwclZ",
"notePath": [
"jdjRLhLV3TtI",
"ibAPHul7Efvr",
"sUqOpnrQyEC7",
"czgXkoEYwclZ"
],
"title": "Running a development build",
"prefix": null,
"dataFileName": "Running a development build.clone.md",
"type": "text",
"format": "markdown",
"isExpanded": false
}
]
},

View File

@ -7,7 +7,6 @@ A fork of [isaul32/ckeditor5-math](https://github.com/isaul32/ckeditor5-math), w
* Tested on Node.js 20.
* The package manager is yarn 1 (v1.22.22 is known to be working fine for it at the time of writing).
* Committing is protected by `husky` which runs `eslint` to ensure that the code is clean.
Important commands:

View File

@ -0,0 +1,6 @@
# CKEditor
* We migrated away from the legacy CKEditor builds using Webpack and instead use the prebuilt npm binaries.
* The role of the `packages/ckeditor5` is to gather the CKEditor for consumption by the client, which includes plugin definitions.
* The internal Trilium plugins (e.g. cut to note, include note) are present in `packages/ckeditor5/src/plugins`.
* External CKEditor plugins that needed adjustments are present in `packages/ckeditor5-*`.
* To integrate a new plugin, see <a class="reference-link" href="CKEditor/Plugin%20migration%20guide.md">Plugin migration guide</a>.

View File

@ -0,0 +1,84 @@
# Plugin migration guide
This guide walks through the basic steps to take to integrate a CKEditor 5 plugin for use inside the Trilium monorepo, which allows:
* Making modifications to the implementation without having to maintain a new repo.
* Integrating an older plugin based on the legacy installation method so that it works well with the new one.
> [!IMPORTANT]
> This guide assumes that the CKEditor plugin is written in TypeScript. If it isn't, then you will have to port it to TypeScript to match the rest of the monorepo.
## Step 1. Creating the project skeleton
First, we are going to generate a project from scratch so that it picks up the latest template for building CKEditor plugins, whereas the plugin which is being integrated might be based on the legacy method.
Outside the `Notes` repository, we are going to use the CKEditor generator to generate the new project structure for us. We are not doing it directly inside `Notes` repository since it's going to use a different package manager (Yarn/NPM vs `pnpm`) and it also creates its own Git repository.
```
npx ckeditor5-package-generator @triliumnext/ckeditor5-foo --use-npm --lang ts --installation-methods current
```
Of course, replace `foo` with the name of the plugin. Generally it's better to stick with the original name of the plugin which can be determined by looking at the prefix of file names (e.g. `mermaid` from `mermaidui` or `mermaidediting`).
## Step 2. Copy the new project
1. Go to the newly created `ckeditor5-foo` directory.
2. Remove `node_modules` since we are going to use `pnpm` to handle it.
3. Remove `.git` from it.
4. Copy the folder into the `Notes` repo, as a subdirectory of `packages`.
## Step 3. Updating dependencies
In the newly copied package, go to `package.json` and edit:
1. In `devDependencies`, change `ckeditor5` from `latest` to the same version as the one described in `packages/ckeditor5/package.json` (fixed version, e.g. `43.2.0`).
2. In `peerDependencies`, change `ckeditor5` to the same version as from the previous step.
3. Similarly, update `vitest` dependencies to match the monorepo one.
4. Remove the `prepare` entry from the `scripts` section.
5. Change `build:dist` to simply `build` in order to integrate it with NX.
## Step 4. Install missing dependencies and build errors
Run `pnpm build-dist` on the `Notes` root, and:
1. If there is an error about `Invalid module name in augmentation, module '@ckeditor/ckeditor5-core' cannot be found.`, simply replace `@ckeditor/ckeditor5-core` with `ckeditor5`.
2. Run the build command again and ensure there are no build errors.
3. Commit the changes.
## Step 5. Using `git subtree` to pull in the original repo
Instead of copying the files from the existing plugin we are actually going to carry over the history for traceability. To do so, we will use a temporary directory inside the repo:
```
git subtree add --prefix=_regroup/<name> https://[...]/repo.git <main_branch>
```
This will bring in all the commits of the upstream repo from the provided branch and rewrite them to be placed under the desired directory.
## Step 6. Integrate the plugin
1. Start by copying each sub-plugin (except the main one such as `FooEditing` and `FooUI`).
1. If they are written in JavaScript, port them to TypeScript.
1. Remove any non-TypeScript type documentation.
2. If they have non-standard imports to CKEditor, such as `'ckeditor5/src/core.js'`, rewrite them to simply `ckeditor`.
2. Install any necessary dependencies used by the source code (try going based on compilation errors rather than simply copying over all dependencies from `package.json`).
3. Keep the existing TypeScript files that were generated automatically and integrate the changes into them.
4. In `tsconfig.json` of the plugin, set `compilerOptions.composite` to `true`.
5. Add a workspace dependency to the new plugin in `packages/ckeditor5/package.json`.
6. In `packages/ckeditor5` look for `plugins.ts` and import the top-level plugin in `EXTERNAL_PLUGINS`.
## Handling CSS
Some plugins have custom CSS whereas some don't.
1. `import` the CSS in the `index.ts` of the plugin.
2. When building the plugin, `dist/index.css` will be updated.
3. In `plugins.ts` from `packages/ckeditor5`, add an import to the CSS.
## Integrating from another monorepo
This is a more complicated use-case if the upstream plugin belongs to a monorepo of another project (similar to how `trilium-ckeditor5` used to be).
1. Create a fresh Git clone of the upstream monorepo to obtain the plugin from.
2. Run `git filter-repo --path packages/ckeditor5-foo/` (the trailing slash is very important!).
3. Run `git subtree add` just like in the previous steps but point to the local Git directory instead (by appending `/.git` to the absolute path of the repository).
4. Follow same integration steps as normal.

View File

@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.0.1",
"appVersion": "0.94.0",
"files": [
{
"isClone": false,

View File

@ -19,6 +19,10 @@
## ✨ Improvements
* Improved the text editor style, to match the TriliumNext.
* Footnotes work in image captions by @werererer
* Improvements to text notes, thanks updates to the editor (see the in-app help for more details):
* Bookmarks, similar to HTML anchors.
* Emojis.
## 📖 Documentation
@ -35,4 +39,5 @@
* For documentation please consult [Notes/docs/Developer Guide/Developer Guide/Environment Setup.md at develop · TriliumNext/Notes](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) and [Notes/docs/Developer Guide/Developer Guide/Project Structure.md at develop · TriliumNext/Notes](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Project%20Structure.md).
* A large number of [dependency updates](https://github.com/TriliumNext/Notes/milestone/13).
* OpenAPI documentation fixes by @FliegendeWurst
* more info on several database table by @FliegendeWurst
* more info on several database table by @FliegendeWurst
* CKEditor (the editor used for text notes) has been updated 7 versions, from v42 to 45.

View File

@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.0.1",
"appVersion": "0.94.0",
"files": [
{
"isClone": false,
@ -5073,6 +5073,65 @@
}
]
},
{
"isClone": false,
"noteId": "oSuaNgyyKnhu",
"notePath": [
"pOsGYCXsbNQG",
"KSZ04uQ2D1St",
"iPIMuisry3hd",
"oSuaNgyyKnhu"
],
"title": "Bookmarks",
"notePosition": 20,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "QEAPj01N5f7w",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "nRhnJkTT8cPs",
"isInheritable": false,
"position": 20
},
{
"type": "label",
"name": "iconClass",
"value": "bx bx-bookmark",
"isInheritable": false,
"position": 10
}
],
"format": "markdown",
"dataFileName": "Bookmarks.md",
"attachments": [
{
"attachmentId": "2cn9iY3Qgyjs",
"title": "plus.png",
"role": "image",
"mime": "image/png",
"position": 10,
"dataFileName": "Bookmarks_plus.png"
},
{
"attachmentId": "JaiAT3dHDIyy",
"title": "plus.png",
"role": "image",
"mime": "image/png",
"position": 10,
"dataFileName": "1_Bookmarks_plus.png"
}
]
},
{
"isClone": false,
"noteId": "veGu4faJErEM",
@ -5083,7 +5142,7 @@
"veGu4faJErEM"
],
"title": "Content language & Right-to-left support",
"notePosition": 20,
"notePosition": 30,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5120,7 +5179,7 @@
"2x0ZAX9ePtzV"
],
"title": "Cut to subnote",
"notePosition": 30,
"notePosition": 40,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5157,7 +5216,7 @@
"UYuUB1ZekNQU"
],
"title": "Developer-specific formatting",
"notePosition": 40,
"notePosition": 50,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5355,7 +5414,7 @@
"AgjCISero73a"
],
"title": "Footnotes",
"notePosition": 50,
"notePosition": 60,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5400,7 +5459,7 @@
"nRhnJkTT8cPs"
],
"title": "Formatting toolbar",
"notePosition": 60,
"notePosition": 70,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5482,7 +5541,7 @@
"Gr6xFaF6ioJ5"
],
"title": "General formatting",
"notePosition": 70,
"notePosition": 80,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5579,7 +5638,7 @@
"AxshuNRegLAv"
],
"title": "Highlights list",
"notePosition": 80,
"notePosition": 90,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5651,7 +5710,7 @@
"mT0HEkOsz6i1"
],
"title": "Images",
"notePosition": 90,
"notePosition": 100,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5901,7 +5960,7 @@
"nBAXQFj20hS1"
],
"title": "Include Note",
"notePosition": 100,
"notePosition": 110,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5938,7 +5997,7 @@
"CohkqWQC1iBv"
],
"title": "Insert buttons",
"notePosition": 110,
"notePosition": 120,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -5954,31 +6013,38 @@
{
"type": "relation",
"name": "internalLink",
"value": "s1aBHPd79XYj",
"value": "oSuaNgyyKnhu",
"isInheritable": false,
"position": 20
},
{
"type": "relation",
"name": "internalLink",
"value": "nBAXQFj20hS1",
"value": "s1aBHPd79XYj",
"isInheritable": false,
"position": 30
},
{
"type": "relation",
"name": "internalLink",
"value": "NRnIZmSMc5sj",
"value": "nBAXQFj20hS1",
"isInheritable": false,
"position": 40
},
{
"type": "relation",
"name": "internalLink",
"value": "YfYAtQBcfo5V",
"value": "NRnIZmSMc5sj",
"isInheritable": false,
"position": 50
},
{
"type": "relation",
"name": "internalLink",
"value": "YfYAtQBcfo5V",
"isInheritable": false,
"position": 60
},
{
"type": "label",
"name": "iconClass",
@ -6014,6 +6080,14 @@
"position": 10,
"dataFileName": "2_Insert buttons_image.png"
},
{
"attachmentId": "Mj8uDOt36GM8",
"title": "plus.png",
"role": "image",
"mime": "image/png",
"position": 10,
"dataFileName": "Insert buttons_plus.png"
},
{
"attachmentId": "N1WMDAlCsrdy",
"title": "image.png",
@ -6061,6 +6135,14 @@
"mime": "image/png",
"position": 10,
"dataFileName": "8_Insert buttons_image.png"
},
{
"attachmentId": "wTs0nELuclAk",
"title": "plus.png",
"role": "image",
"mime": "image/png",
"position": 10,
"dataFileName": "1_Insert buttons_plus.png"
}
]
},
@ -6074,7 +6156,7 @@
"oiVPnW8QfnvS"
],
"title": "Keyboard shortcuts",
"notePosition": 120,
"notePosition": 130,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -6109,7 +6191,7 @@
"QEAPj01N5f7w"
],
"title": "Links",
"notePosition": 130,
"notePosition": 140,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -6198,7 +6280,7 @@
"S6Xx8QIWTV66"
],
"title": "Lists",
"notePosition": 140,
"notePosition": 150,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -6322,7 +6404,7 @@
"QrtTYPmdd1qq"
],
"title": "Markdown-like formatting",
"notePosition": 150,
"notePosition": 160,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -6373,10 +6455,17 @@
{
"type": "relation",
"name": "internalLink",
"value": "dEHYtoWWi8ct",
"value": "CohkqWQC1iBv",
"isInheritable": false,
"position": 70
},
{
"type": "relation",
"name": "internalLink",
"value": "dEHYtoWWi8ct",
"isInheritable": false,
"position": 80
},
{
"type": "label",
"name": "iconClass",
@ -6399,7 +6488,7 @@
"YfYAtQBcfo5V"
],
"title": "Math Equations",
"notePosition": 160,
"notePosition": 170,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -6465,7 +6554,7 @@
"dEHYtoWWi8ct"
],
"title": "Other features",
"notePosition": 170,
"notePosition": 180,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -6578,7 +6667,7 @@
"BFvAtE74rbP6"
],
"title": "Table of contents",
"notePosition": 180,
"notePosition": 190,
"prefix": null,
"isExpanded": false,
"type": "text",
@ -6643,7 +6732,7 @@
"NdowYOC1GFKS"
],
"title": "Tables",
"notePosition": 190,
"notePosition": 200,
"prefix": null,
"isExpanded": false,
"type": "text",

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,19 @@
# Bookmarks
Bookmarks allows creating [links](Links.md) to a certain part of a note, such as referencing a particular heading.
Technically, bookmarks are HTML anchors.
This feature was introduced in TriliumNext 0.94.0.
## Interaction
* To create a bookmark:
* Place the cursor at the desired position where to place the bookmark.
* Look for the <img src="Bookmarks_plus.png" width="15" height="16"> button in the <a class="reference-link" href="Formatting%20toolbar.md">Formatting toolbar</a>, and then press the <img src="1_Bookmarks_plus.png" width="12" height="15"> button.
* To place a link to a bookmark:
* Place the cursor at the desired position of the link.
* From the [link](Links.md) pane, select the _Bookmarks_ section and select the desired bookmark.
## Limitations
* Currently it's not possible to create a link to a bookmark from a different note. This functionality will be added after the internal links feature is enhanced to support bookmarks.

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

View File

@ -1,6 +1,22 @@
# Insert buttons
Press the <img src="4_Insert buttons_image.png" width="34" height="16"> button in the <a class="reference-link" href="Formatting%20toolbar.md">Formatting toolbar</a> to reveal special inserable items and blocks such as symbols, Math expressions and separators.
## Bookmarks
See the dedicated <a class="reference-link" href="Bookmarks.md">Bookmarks</a> section.
## Emoji
<figure class="image image-style-align-right image_resized" style="width:42.4%;"><img style="aspect-ratio:366/410;" src="Insert buttons_plus.png" width="366" height="410"></figure>
This feature allows inserting Unicode emoji characters. Simply select a category and a desired emoji to insert it.
Emojis can also be searched by their English name and the skin tone can be selected via a combo box to the right.
There is also the possibility of inserting emojis directly by typing `:` followed by a name of an emoji, triggering the display of a list of emojis. Simply use the arrow keys to select one and press <kbd>Enter</kbd> to insert it.
<img src="1_Insert buttons_plus.png" width="272" height="187">
## Symbols
<figure class="image image-style-align-right"><img style="aspect-ratio:346/322;" src="1_Insert buttons_image.png" width="346" height="322"></figure>

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -27,5 +27,6 @@ To import more complex formatting into text notes, consider using the [_Import f
* `!!! caution`
* `!!! warning`
* Starting any other text with `!!!` will insert a note admonition with the text inside of it.
* For [emojis](Insert%20buttons.md), type `:` followed by an emoji name to trigger an auto-completion.
If auto-formatting is not desirable, press <kbd>Ctrl</kbd> + <kbd>Z</kbd> to revert the text to its original form.

View File

@ -3,7 +3,7 @@
Within text notes, it's possible to enter mathematical equations using the <img src="1_Math Equations_image.png" width="20" height="15"> button from the <a class="reference-link" href="Formatting%20toolbar.md">Formatting toolbar</a> (generally found under the <a class="reference-link" href="Insert%20buttons.md">Insert buttons</a>).
If inserting equations frequently, using the <kbd>Ctrl</kbd>+<kbd>M</kbd> keyboard shortcut can be more comfortable.
If inserting equations frequently, using the <kbd>Ctrl</kbd>+<kbd>M</kbd> keyboard shortcut can be more comfortable. Alternatively, type `$$` or `\[` to trigger the popup directly.
There is currently no quick way to insert an equation, such as surrounding it with `$` or pressing <kbd>Ctrl</kbd>+<kbd>M</kbd> on an already typed-out equation.

View File

@ -19,23 +19,25 @@
"chore:generate-openapi": "tsx ./scripts/generate-openapi.ts",
"chore:update-build-info": "tsx ./scripts/update-build-info.ts",
"chore:update-version": "tsx ./scripts/update-version.ts",
"test": "pnpm nx run-many -t test",
"test": "pnpm test:parallel && pnpm test:sequential",
"test:parallel": "pnpm nx run-many -t test --all --exclude=ckeditor5-mermaid,ckeditor5-math --parallel",
"test:sequential": "pnpm nx run-many -t test --projects=ckeditor5-mermaid,ckeditor5-math --parallel=1",
"coverage": "pnpm nx run-many -t coverage"
},
"private": true,
"devDependencies": {
"@eslint/js": "^9.8.0",
"@nx/devkit": "20.8.1",
"@nx/esbuild": "20.8.1",
"@nx/eslint": "20.8.1",
"@nx/eslint-plugin": "20.8.1",
"@nx/express": "20.8.1",
"@nx/js": "20.8.1",
"@nx/node": "20.8.1",
"@nx/playwright": "20.8.1",
"@nx/vite": "20.8.1",
"@nx/web": "20.8.1",
"@nx/webpack": "20.8.1",
"@electron/rebuild": "4.0.1",
"@nx/devkit": "21.0.3",
"@nx/esbuild": "21.0.3",
"@nx/eslint": "21.0.3",
"@nx/eslint-plugin": "21.0.3",
"@nx/express": "21.0.3",
"@nx/js": "21.0.3",
"@nx/node": "21.0.3",
"@nx/playwright": "21.0.3",
"@nx/vite": "21.0.3",
"@nx/web": "21.0.3",
"@nx/webpack": "21.0.3",
"@playwright/test": "^1.36.0",
"@svgr/webpack": "^8.0.1",
"@swc-node/register": "~1.10.0",
@ -44,9 +46,10 @@
"@swc/helpers": "~0.5.11",
"@triliumnext/server": "workspace:*",
"@types/express": "^4.17.21",
"@types/node": "22.15.3",
"@types/node": "22.15.17",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/ui": "^3.0.0",
"chalk": "5.4.1",
"cross-env": "7.0.3",
"esbuild": "^0.25.0",
"eslint": "^9.8.0",
@ -55,14 +58,16 @@
"jiti": "2.4.2",
"jsdom": "~26.1.0",
"jsonc-eslint-parser": "^2.1.0",
"nx": "20.8.1",
"react-refresh": "^0.10.0",
"nx": "21.0.3",
"react-refresh": "^0.17.0",
"swc-loader": "0.2.6",
"tslib": "^2.3.0",
"tsx": "4.19.4",
"typescript": "~5.8.0",
"typescript-eslint": "^8.19.0",
"upath": "2.0.1",
"vite": "^6.0.0",
"vite-plugin-dts": "~4.5.0",
"vitest": "^3.0.0",
"webpack-cli": "^6.0.0"
},
@ -84,5 +89,12 @@
"axios": "^1.6.0",
"express": "^4.21.2"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
"pnpm": {
"patchedDependencies": {
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
"@ckeditor/ckeditor5-code-block": "patches/@ckeditor__ckeditor5-code-block.patch",
"ckeditor5": "patches/ckeditor5.patch"
}
}
}

View File

@ -0,0 +1,19 @@
# Configurations to normalize the IDE behavior.
# http://editorconfig.org/
root = true
[*]
indent_style = tab
tab_width = 4
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,jsx,ts}]
quote_type = single
[package.json]
indent_style = space
tab_width = 2

View File

@ -0,0 +1,46 @@
/* eslint-env node */
'use strict';
module.exports = {
extends: 'ckeditor5',
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint'
],
root: true,
ignorePatterns: [
// Ignore the entire `dist/` (the NIM build).
'dist/**',
// Ignore compiled JavaScript files, as they are generated automatically.
'src/**/*.js',
// Also, do not check typing declarations, too.
'src/**/*.d.ts'
],
rules: {
// This rule disallows importing from any path other than the package main entrypoint.
'ckeditor5-rules/allow-imports-only-from-main-package-entry-point': 'error',
// This rule ensures that all imports from `@ckeditor/*` packages are done through the main package entry points.
// This is required for the editor types to work properly and to ease migration to the installation methods
// introduced in CKEditor 5 version 42.0.0.
'ckeditor5-rules/no-legacy-imports': 'error',
// As required by the ECMAScript (ESM) standard, all imports must include a file extension.
// If the import does not include it, this rule will try to automatically detect the correct file extension.
'ckeditor5-rules/require-file-extensions-in-imports': [
'error',
{
extensions: [ '.ts', '.js', '.json' ]
}
]
},
overrides: [
{
files: [ 'tests/**/*.[jt]s', 'sample/**/*.[jt]s' ],
rules: {
// To write complex tests, you may need to import files that are not exported in DLL files by default.
// Hence, imports CKEditor 5 packages in test files are not checked.
'ckeditor5-rules/ckeditor-imports': 'off'
}
}
]
};

View File

@ -0,0 +1,19 @@
* text=auto
*.htaccess eol=lf
*.cgi eol=lf
*.sh eol=lf
*.css text
*.htm text
*.html text
*.js text
*.ts text
*.json text
*.php text
*.txt text
*.md text
*.png -text
*.gif -text
*.jpg -text

View File

@ -0,0 +1,10 @@
build/
coverage/
dist/
node_modules/
tmp/
sample/ckeditor.dist.js
# Ignore compiled TypeScript files.
src/**/*.js
src/**/*.d.ts

View File

@ -0,0 +1,3 @@
{
"extends": "stylelint-config-ckeditor5"
}

View File

@ -0,0 +1,6 @@
Software License Agreement
==========================
Copyright (c) 2025. All rights reserved.
Licensed under the terms of [MIT license](https://opensource.org/licenses/MIT).

View File

@ -0,0 +1,141 @@
@triliumnext/ckeditor5-admonition
=================================
This package was created by the [ckeditor5-package-generator](https://www.npmjs.com/package/ckeditor5-package-generator) package.
## Table of contents
* [Developing the package](#developing-the-package)
* [Available scripts](#available-scripts)
* [`start`](#start)
* [`test`](#test)
* [`lint`](#lint)
* [`stylelint`](#stylelint)
* [`build:dist`](#builddist)
* [`translations:synchronize`](#translationssynchronize)
* [`translations:validate`](#translationsvalidate)
* [`ts:build` and `ts:clear`](#tsbuild-and-tsclear)
* [License](#license)
## Developing the package
To read about the CKEditor 5 Framework, visit the [CKEditor 5 Framework documentation](https://ckeditor.com/docs/ckeditor5/latest/framework/index.html).
## Available scripts
NPM scripts are a convenient way to provide commands in a project. They are defined in the `package.json` file and shared with people contributing to the project. It ensures developers use the same command with the same options (flags).
All the scripts can be executed by running `npm run <script>`. Pre and post commands with matching names will be run for those as well.
The following scripts are available in the package.
### `start`
Starts an HTTP server with the live-reload mechanism that allows previewing and testing of plugins available in the package.
When the server starts, the default browser will open the developer sample. This can be disabled by passing the `--no-open` option to that command.
You can also define the language that will translate the created editor by specifying the `--language [LANG]` option. It defaults to `'en'`.
Examples:
```bash
# Starts the server and open the browser.
npm run start
# Disable auto-opening the browser.
npm run start -- --no-open
# Create the editor with the interface in German.
npm run start -- --language=de
```
### `test`
Allows executing unit tests for the package specified in the `tests/` directory. To check the code coverage, add the `--coverage` modifier. See other [CLI flags](https://vitest.dev/guide/cli.html) in Vitest.
Examples:
```bash
# Execute tests.
npm run test
# Generate code coverage report after each change in the sources.
npm run test -- --coverage
```
### `lint`
Runs ESLint, which analyzes the code (all `*.ts` files) to quickly find problems.
Examples:
```bash
# Execute eslint.
npm run lint
```
### `stylelint`
Similar to the `lint` task, stylelint analyzes the CSS code (`*.css` files in the `theme/` directory) in the package.
Examples:
```bash
# Execute stylelint.
npm run stylelint
```
### `build:dist`
Creates npm and browser builds of your plugin. These builds can be added to the editor by following the [Configuring CKEditor 5 features](https://ckeditor.com/docs/ckeditor5/latest/getting-started/setup/configuration.html) guide.
Examples:
```bash
# Builds the `npm` and browser files thats are ready to publish.
npm run build:dist
```
### `translations:synchronize`
Synchronizes translation messages (arguments of the `t()` function) by performing the following steps:
* Collect all translation messages from the package by finding `t()` calls in source files.
* Detect if translation context is valid, i.e. whether the provided values do not interfere with the values specified in the `@ckeditor/ckeditor5-core` package.
* If there are no validation errors, update all translation files (`*.po` files) to be in sync with the context file:
* unused translation entries are removed,
* missing translation entries are added with empty string as the message translation,
* missing translation files are created for languages that do not have own `*.po` file yet.
The task may end with an error if one of the following conditions is met:
* Found the `Unused context` error &ndash; entries specified in the `lang/contexts.json` file are not used in source files. They should be removed.
* Found the `Duplicated contex` error &ndash; some of the entries are duplicated. Consider removing them from the `lang/contexts.json` file, or rewriting them.
* Found the `Missing context` error &ndash; entries specified in source files are not described in the `lang/contexts.json` file. They should be added.
Examples:
```bash
npm run translations:synchronize
```
### `translations:validate`
Peforms only validation steps as described in [`translations:synchronize`](#translationssynchronize) script, but without modifying any files. It only checks the correctness of the context file against the `t()` function calls.
Examples:
```bash
npm run translations:validate
```
### `ts:build` and `ts:clear`
These scripts compile TypeScript and remove the compiled files. They are used in the aforementioned life cycle scripts, and there is no need to call them manually.
## License
The `@triliumnext/ckeditor5-admonition` package is available under [MIT license](https://opensource.org/licenses/MIT).
However, it is the default license of packages created by the [ckeditor5-package-generator](https://www.npmjs.com/package/ckeditor5-package-generator) package and can be changed.

View File

@ -0,0 +1,22 @@
{
"plugins": [
{
"name": "Admonition",
"className": "Admonition",
"description": "Implements admonitions (warning, info boxes) in a similar fashion to blockquotes",
"path": "src/admonition.ts",
"uiComponents": [
{
"name": "admonition",
"type": "Button",
"iconPath": "theme/icons/admonition.svg"
}
],
"htmlOutput": [
{
"elements": "aside"
}
]
}
]
}

View File

@ -0,0 +1,3 @@
{
"Admonition": "Toolbar button tooltip for the Admonition feature."
}

View File

@ -0,0 +1,22 @@
# Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
#
# !!! IMPORTANT !!!
#
# Before you edit this file, please keep in mind that contributing to the project
# translations is possible ONLY via the Transifex online service.
#
# To submit your translations, visit https://www.transifex.com/ckeditor/ckeditor5.
#
# To learn more, check out the official contributor's guide:
# https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html
#
msgid ""
msgstr ""
"Language: \n"
"Language-Team: \n"
"Plural-Forms: \n"
"Content-Type: text/plain; charset=UTF-8\n"
msgctxt "Toolbar button tooltip for the Admoniton feature."
msgid "Admonition"
msgstr "Admonition"

View File

@ -0,0 +1,90 @@
{
"name": "@triliumnext/ckeditor5-admonition",
"version": "1.0.0",
"description": "Admonition (info box, warning box) feature for CKEditor 5.",
"keywords": [
"ckeditor",
"ckeditor5",
"ckeditor 5",
"ckeditor5-feature",
"ckeditor5-plugin",
"ckeditor5-package-generator"
],
"type": "module",
"main": "dist/index.ts",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./*": "./dist/*",
"./browser/*": null,
"./package.json": "./package.json"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=5.7.1"
},
"files": [
"dist",
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "43.0.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "^3.0.1",
"@typescript-eslint/eslint-plugin": "~8.32.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/browser": "^3.0.5",
"@vitest/coverage-istanbul": "^3.0.5",
"ckeditor5": "45.0.0",
"eslint": "^9.0.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "^14.1.0",
"lint-staged": "^15.0.0",
"stylelint": "^16.0.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "^10.9.1",
"typescript": "5.0.4",
"vite-plugin-svgo": "~2.0.0",
"vitest": "^3.0.5",
"webdriverio": "^9.0.7"
},
"peerDependencies": {
"ckeditor5": "45.0.0"
},
"author": "Elian Doran <contact@eliandoran.me>",
"license": "GPL-2.0-or-later",
"scripts": {
"build": "node ./scripts/build-dist.mjs",
"ts:build": "tsc -p ./tsconfig.release.json",
"ts:clear": "npx rimraf --glob \"src/**/*.@(js|d.ts)\"",
"lint": "eslint \"**/*.{js,ts}\" --quiet",
"start": "ckeditor5-package-tools start",
"stylelint": "stylelint --quiet --allow-empty-input 'theme/**/*.css'",
"test": "vitest",
"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false",
"prepublishOnly": "npm run ts:build && ckeditor5-package-tools export-package-as-javascript",
"postpublish": "npm run ts:clear && ckeditor5-package-tools export-package-as-typescript",
"translations:synchronize": "ckeditor5-package-tools translations:synchronize",
"translations:validate": "ckeditor5-package-tools translations:synchronize --validate-only"
},
"lint-staged": {
"**/*.{js,ts}": [
"eslint --quiet"
],
"**/*.css": [
"stylelint --quiet --allow-empty-input"
]
},
"nx": {
"name": "ckeditor5-admonition",
"targets": {
"build": {
"cache": "true"
}
}
}
}

View File

@ -0,0 +1,113 @@
declare global {
interface Window {
editor: ClassicEditor;
}
}
import {
ClassicEditor,
Autoformat,
Base64UploadAdapter,
BlockQuote,
Bold,
Code,
CodeBlock,
Essentials,
Heading,
Image,
ImageCaption,
ImageStyle,
ImageToolbar,
ImageUpload,
Indent,
Italic,
Link,
List,
MediaEmbed,
Paragraph,
Table,
TableToolbar
} from 'ckeditor5';
import CKEditorInspector from '@ckeditor/ckeditor5-inspector';
import Admonition from '../src/admonition.js';
import 'ckeditor5/ckeditor5.css';
ClassicEditor
.create( document.getElementById( 'editor' )!, {
licenseKey: 'GPL',
plugins: [
Admonition,
Essentials,
Autoformat,
BlockQuote,
Bold,
Heading,
Image,
ImageCaption,
ImageStyle,
ImageToolbar,
ImageUpload,
Indent,
Italic,
Link,
List,
MediaEmbed,
Paragraph,
Table,
TableToolbar,
CodeBlock,
Code,
Base64UploadAdapter
],
toolbar: [
'undo',
'redo',
'|',
'admonition',
'|',
'heading',
'|',
'bold',
'italic',
'link',
'code',
'bulletedList',
'numberedList',
'|',
'outdent',
'indent',
'|',
'uploadImage',
'blockQuote',
'insertTable',
'mediaEmbed',
'codeBlock'
],
image: {
toolbar: [
'imageStyle:inline',
'imageStyle:block',
'imageStyle:side',
'|',
'imageTextAlternative'
]
},
table: {
contentToolbar: [
'tableColumn',
'tableRow',
'mergeTableCells'
]
}
} )
.then( editor => {
window.editor = editor;
CKEditorInspector.attach( editor );
window.console.log( 'CKEditor 5 is ready.', editor );
} )
.catch( err => {
window.console.error( err.stack );
} );

View File

@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="icon" type="image/png" href="https://ckeditor.com/docs/ckeditor5/latest/assets/img/favicons/32x32.png" sizes="32x32">
<meta charset="utf-8">
<title>CKEditor 5 DLL Sample</title>
<style>
body {
max-width: 800px;
margin: 20px auto;
}
</style>
</head>
<body>
<h1>CKEditor 5 DLL Sample</h1>
<div id="editor">
<h2>Production sample</h2>
<p>
This is a demo of the <a href="https://ckeditor.com/docs/ckeditor5/latest/builds/guides/overview.html#classic-editor">classic editor
build</a>, initialized using the <a href="https://ckeditor.com/docs/ckeditor5/latest/builds/guides/development/dll-builds.html">DLL builds</a>.
</p>
<p>
Your plugin (Admonition) generated by the tool is already loaded into the editor. By default, it has an example button that adds some text to the editor. Whenever you change the plugin's name or toolbar items, make sure to update the editor configuration in the <code>sample/dll.html</code> file.
</p>
<h3>Uncaught TypeError</h3>
<p>If the editor is not initialized correctly and the browser console displays an error such as the following:</p>
<pre><code>Uncaught TypeError: Cannot read properties of undefined (reading 'Admonition') at dll.html:85</code></pre>
<p>it means that the DLL build of the <code>@triliumnext/ckeditor5-admonition</code> package has not been created.</p>
<p>Please call the <code>npm run dll:build</code> command in the CLI, and after it finishes, refresh the page.</p>
<h3>Anatomy of the DLL build</h3>
<p>The source of the DLL build is located in the <code>src/index.ts</code> file. It may export as many things as the package offers.</p>
<h4>Maintaining the sample</h4>
<p>Whenever you change objects exported by the DLL build, please review the content of the sample. Things to keep in mind:</p>
<ul>
<li>Review the list of loaded plugins in the configuration.</li>
<li>Review names of items registered in toolbars.</li>
</ul>
<h3>The goal</h3>
<p>The primary purpose of the sample is to verify whether the plugins in the package will work together with CKEditor 5 created with the DLL approach.</p>
<h3>Publishing the package</h3>
<p>
While releasing TypeScript package on npm, few things have to be taken care of:
</p>
<ul>
<li>Building DLL</li>
<li>Building TypeScript</li>
<li>Changing the <code>main</code> filed in <code>package.json</code> to <code>.js</code> extension</li>
</ul>
<p>
Likewise, after the release, there are few steps:
</p>
<ul>
<li>Deleting compiled TypeScript files (they are generated in the <code>src</code> directory, and create needless clutter)</li>
<li>Changing the <code>main</code> filed in <code>package.json</code> back to <code>.ts</code> extension</li>
</ul>
<p>
When calling <code>npm publish</code>, those steps are handled automatically by predefined <code>prepublishOnly</code> and <code>postpublish</code> scripts. To learn more, see <a href="https://docs.npmjs.com/cli/v7/using-npm/scripts#pre--post-scripts">NPM docs</a>.
</p>
<h3>Reporting issues</h3>
<p>If you found a problem with CKEditor 5 or the package generator, please, report an issue:</p>
<ul>
<li><a href="https://github.com/ckeditor/ckeditor5/issues/new/choose">CKEditor 5</a></li>
<li><a href="https://github.com/ckeditor/create-ckeditor5-plugin/issues/new">The package generator</a></li>
</ul>
</div>
<!-- DLL builds are served from the `node_modules/` directory -->
<script src="../node_modules/ckeditor5/build/ckeditor5-dll.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-editor-classic/build/editor-classic.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-code-block/build/code-block.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-essentials/build/essentials.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-basic-styles/build/basic-styles.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-heading/build/heading.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-autoformat/build/autoformat.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-block-quote/build/block-quote.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-image/build/image.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-link/build/link.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-indent/build/indent.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-media-embed/build/media-embed.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-list/build/list.js"></script>
<script src="../node_modules/@ckeditor/ckeditor5-table/build/table.js"></script>
<!-- The "@triliumnext/ckeditor5-admonition" package DLL build is served from the `build/` directory -->
<script src="../build/admonition.js"></script>
<script>
console.log( 'Objects exported by the DLL build:', CKEditor5[ 'admonition' ] );
CKEditor5.editorClassic.ClassicEditor
.create( document.querySelector( '#editor' ), {
plugins: [
CKEditor5[ 'admonition' ].Admonition,
CKEditor5.essentials.Essentials,
CKEditor5.autoformat.Autoformat,
CKEditor5.blockQuote.BlockQuote,
CKEditor5.basicStyles.Bold,
CKEditor5.heading.Heading,
CKEditor5.image.Image,
CKEditor5.image.ImageCaption,
CKEditor5.image.ImageStyle,
CKEditor5.image.ImageToolbar,
CKEditor5.image.ImageUpload,
CKEditor5.indent.Indent,
CKEditor5.basicStyles.Italic,
CKEditor5.link.Link,
CKEditor5.list.List,
CKEditor5.mediaEmbed.MediaEmbed,
CKEditor5.paragraph.Paragraph,
CKEditor5.table.Table,
CKEditor5.table.TableToolbar,
CKEditor5.codeBlock.CodeBlock,
CKEditor5.basicStyles.Code,
CKEditor5.upload.Base64UploadAdapter
],
toolbar: [
'admonition',
'|',
'heading',
'|',
'bold',
'italic',
'link',
'code',
'bulletedList',
'numberedList',
'|',
'outdent',
'indent',
'|',
'uploadImage',
'blockQuote',
'insertTable',
'mediaEmbed',
'codeBlock',
'|',
'undo',
'redo'
],
image: {
toolbar: [
'imageStyle:inline',
'imageStyle:block',
'imageStyle:side',
'|',
'imageTextAlternative'
]
},
table: {
contentToolbar: [
'tableColumn',
'tableRow',
'mergeTableCells'
]
}
} )
.then( editor => {
window.editor = editor;
} )
.catch( error => {
console.error( 'There was a problem initializing the editor.', error );
} );
</script>
</body>
</html>

View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="icon" type="image/png" href="https://ckeditor.com/docs/ckeditor5/latest/assets/img/favicons/32x32.png"
sizes="32x32">
<meta charset="utf-8">
<title>CKEditor 5 Development Sample</title>
<style>
body {
max-width: 800px;
margin: 20px auto;
}
</style>
</head>
<body>
<h1>CKEditor 5 Development Sample</h1>
<div id="editor">
<h2>Development environment</h2>
<p>
This is a demo of the <a
href="https://ckeditor.com/docs/ckeditor5/latest/builds/guides/overview.html#classic-editor">classic
editor
build</a> that loads your plugin (<code>Admonition</code>) generated by the
tool. You can modify this
sample and use it to validate whether a plugin or a set of plugins work fine.
</p>
<p>
<code>Admonition</code> inserts text into the editor. You can click the
CKEditor 5 icon in the toolbar and see the results.
</p>
<h3>Helpful resources</h3>
<ul>
<li>Architecture
<ul>
<li>
<a
href="https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/core-editor-architecture.html">Core
editor architecture</a>
</li>
</ul>
<ul>
<li>
<a
href="https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html">The
editing engine</a>
</li>
</ul>
<ul>
<li>
<a
href="https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/ui-library.html">The
UI library</a>
</li>
</ul>
</li>
<li>
<a
href="https://ckeditor.com/docs/ckeditor5/latest/framework/guides/support/browser-compatibility.html">Browser
compatibility</a>
</li>
<li>
<a href="https://ckeditor.com/docs/ckeditor5/latest/framework/guides/support/error-codes.html">The error
codes</a>
</li>
<li><a href="https://ckeditor.com/docs/ckeditor5/latest/builds/guides/development/dll-builds.html">The DLL
builds</a></li>
</ul>
<h3>The directory structure</h3>
<p>
The code snippet below presents the directory structure.
</p>
<pre><code class="language-plaintext">.
├─ lang
│ └─ contexts.json # Entries used for creating translations.
├─ sample
│ ├─ dll.html # The editor initialized using the DLL builds. Check README for details.
│ ├─ index.html # The currently displayed file.
│ └─ ckeditor.ts # The editor initialization script.
├─ src
│ ├─ admonition.ts
│ ├─ augmentation.ts # Type augmentations for the `@ckeditor/ckeditor5-core` module. Read more in <a href="https://ckeditor.com/docs/ckeditor5/latest/api/module_core_plugincollection-PluginsMap.html">PluginsMap</a> and <a href="https://ckeditor.com/docs/ckeditor5/latest/api/module_core_commandcollection-CommandsMap.html">CommandsMap</a>.
│ ├─ index.ts # The modules exported by the package when using the DLL builds.
│ └─ **/*.ts # All TypeScript source files should be saved here.
├─ tests
│ ├─ admonition.ts
│ ├─ index.ts # Tests for the plugin.
│ └─ **/*.ts # All tests should be saved here.
├─ theme
│ ├─ icons
│ │ ├─ ckeditor.svg # The CKEditor 5 icon displayed in the toolbar.
│ │ └─ **/*.svg # All icon files should be saved here.
│ └─ **/*.css # All CSS files should be saved here.
├─ typings
│ └─ **/*.d.ts # Files containing type definitions.
├─ .editorconfig
├─ ...
├─ README.md
└─ tsconfig.json # TypeScript configuration file.</code></pre>
<h3>Reporting issues</h3>
<p>If you found a problem with CKEditor 5 or the package generator, please, report an issue:</p>
<ul>
<li><a href="https://github.com/ckeditor/ckeditor5/issues/new/choose">CKEditor 5</a></li>
<li><a href="https://github.com/ckeditor/ckeditor5-package-generator/issues/new">The package generator</a>
</li>
</ul>
</div>
<script src="./ckeditor.dist.js"></script>
</body>
</html>

View File

@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* @license Copyright (c) 2020-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/
/* eslint-env node */
import { createRequire } from 'module';
import upath from 'upath';
import chalk from 'chalk';
import { build } from '@ckeditor/ckeditor5-dev-build-tools';
function dist( path ) {
return upath.join( 'dist', path );
}
( async () => {
const tsconfig = 'tsconfig.dist.ckeditor5.json';
/**
* Step 1
*/
console.log( chalk.cyan( '1/2: Generating NPM build...' ) );
const require = createRequire( import.meta.url );
const pkg = require( upath.resolve( process.cwd(), './package.json' ) );
await build( {
input: 'src/index.ts',
output: dist( './index.js' ),
tsconfig: 'tsconfig.dist.json',
external: [
'ckeditor5',
'ckeditor5-premium-features',
...Object.keys( {
...pkg.dependencies,
...pkg.peerDependencies
} )
],
clean: true,
sourceMap: true,
declarations: true,
translations: '**/*.po'
} );
} )();

View File

@ -0,0 +1,17 @@
import { Plugin } from 'ckeditor5';
import AdmonitionEditing from './admonitionediting.js';
import AdmonitionUI from './admonitionui.js';
import AdmonitionAutoformat from './admonitionautoformat.js';
export default class Admonition extends Plugin {
public static get requires() {
return [ AdmonitionEditing, AdmonitionUI, AdmonitionAutoformat ] as const;
}
public static get pluginName() {
return 'Admonition' as const;
}
}

View File

@ -0,0 +1,41 @@
import { Autoformat, blockAutoformatEditing, Plugin } from "ckeditor5";
import { AdmonitionType, ADMONITION_TYPES } from "./admonitioncommand.js";
function tryParseAdmonitionType(match: RegExpMatchArray) {
if (match.length !== 2) {
return;
}
if ((ADMONITION_TYPES as readonly string[]).includes(match[1])) {
return match[1] as AdmonitionType;
}
}
export default class AdmonitionAutoformat extends Plugin {
static get requires() {
return [ Autoformat ];
}
afterInit() {
if (!this.editor.commands.get("admonition")) {
return;
}
const instance = (this as any);
blockAutoformatEditing(this.editor, instance, /^\!\!\[*\! (.+) $/, ({ match }) => {
const type = tryParseAdmonitionType(match);
if (type) {
// User has entered the admonition type, so we insert as-is.
this.editor.execute("admonition", { forceValue: type });
} else {
// User has not entered a valid type, assume it's part of the text of the admonition.
this.editor.execute("admonition");
if (match.length > 1) {
this.editor.execute("insertText", { text: (match[1] ?? "") + " " });
}
}
});
}
}

View File

@ -0,0 +1,275 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module admonition/admonitioncommand
*/
import { Command, first } from 'ckeditor5';
import type { DocumentFragment, Element, Position, Range, Schema, Writer } from 'ckeditor5';
/**
* The block quote command plugin.
*
* @extends module:core/command~Command
*/
export const ADMONITION_TYPES = [ "note", "tip", "important", "caution", "warning" ] as const;
export const ADMONITION_TYPE_ATTRIBUTE = "admonitionType";
export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPES[0];
export type AdmonitionType = typeof ADMONITION_TYPES[number];
interface ExecuteOpts {
/**
* If set, it will force the command behavior. If `true`, the command will apply a block quote,
* otherwise the command will remove the block quote. If not set, the command will act basing on its current value.
*/
forceValue?: AdmonitionType;
/**
* If set to true and `forceValue` is not specified, the command will apply the previous admonition type (if the command was already executed).
*/
usePreviousChoice?: boolean
}
export default class AdmonitionCommand extends Command {
/**
* Whether the selection starts in a block quote.
*
* @observable
* @readonly
*/
declare public value: AdmonitionType | false;
private _lastType?: AdmonitionType;
/**
* @inheritDoc
*/
public override refresh(): void {
this.value = this._getValue();
this.isEnabled = this._checkEnabled();
}
/**
* Executes the command. When the command {@link #value is on}, all top-most block quotes within
* the selection will be removed. If it is off, all selected blocks will be wrapped with
* a block quote.
*
* @fires execute
* @param options Command options.
*/
public override execute( options: ExecuteOpts = {} ): void {
const model = this.editor.model;
const schema = model.schema;
const selection = model.document.selection;
const blocks = Array.from( selection.getSelectedBlocks() );
const value = this._getType(options);
model.change( writer => {
if ( !value ) {
this._removeQuote( writer, blocks.filter( findQuote ) );
} else {
const blocksToQuote = blocks.filter( block => {
// Already quoted blocks needs to be considered while quoting too
// in order to reuse their <bQ> elements.
return findQuote( block ) || checkCanBeQuoted( schema, block );
} );
this._applyQuote( writer, blocksToQuote, value);
}
} );
}
private _getType(options: ExecuteOpts): AdmonitionType | false {
const value = (options.forceValue === undefined) ? !this.value : options.forceValue;
// Allow removing the admonition.
if (!value) {
return false;
}
// Prefer the type from the command, if any.
if (typeof value === "string") {
return value;
}
// See if we can restore the previous language.
if (options.usePreviousChoice && this._lastType) {
return this._lastType;
}
// Otherwise return a default.
return "note";
}
/**
* Checks the command's {@link #value}.
*/
private _getValue(): AdmonitionType | false {
const selection = this.editor.model.document.selection;
const firstBlock = first( selection.getSelectedBlocks() );
if (!firstBlock) {
return false;
}
// In the current implementation, the admonition must be an immediate parent of a block element.
const firstQuote = findQuote( firstBlock );
if (firstQuote?.is("element")) {
return firstQuote.getAttribute(ADMONITION_TYPE_ATTRIBUTE) as AdmonitionType;
}
return false;
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/
private _checkEnabled(): boolean {
if ( this.value ) {
return true;
}
const selection = this.editor.model.document.selection;
const schema = this.editor.model.schema;
const firstBlock = first( selection.getSelectedBlocks() );
if ( !firstBlock ) {
return false;
}
return checkCanBeQuoted( schema, firstBlock );
}
/**
* Removes the quote from given blocks.
*
* If blocks which are supposed to be "unquoted" are in the middle of a quote,
* start it or end it, then the quote will be split (if needed) and the blocks
* will be moved out of it, so other quoted blocks remained quoted.
*/
private _removeQuote( writer: Writer, blocks: Array<Element> ): void {
// Unquote all groups of block. Iterate in the reverse order to not break following ranges.
getRangesOfBlockGroups( writer, blocks ).reverse().forEach( groupRange => {
if ( groupRange.start.isAtStart && groupRange.end.isAtEnd ) {
writer.unwrap( groupRange.start.parent as Element );
return;
}
// The group of blocks are at the beginning of an <bQ> so let's move them left (out of the <bQ>).
if ( groupRange.start.isAtStart ) {
const positionBefore = writer.createPositionBefore( groupRange.start.parent as Element );
writer.move( groupRange, positionBefore );
return;
}
// The blocks are in the middle of an <bQ> so we need to split the <bQ> after the last block
// so we move the items there.
if ( !groupRange.end.isAtEnd ) {
writer.split( groupRange.end );
}
// Now we are sure that groupRange.end.isAtEnd is true, so let's move the blocks right.
const positionAfter = writer.createPositionAfter( groupRange.end.parent as Element );
writer.move( groupRange, positionAfter );
} );
}
/**
* Applies the quote to given blocks.
*/
private _applyQuote( writer: Writer, blocks: Array<Element>, type?: AdmonitionType): void {
this._lastType = type;
const quotesToMerge: Array<Element | DocumentFragment> = [];
// Quote all groups of block. Iterate in the reverse order to not break following ranges.
getRangesOfBlockGroups( writer, blocks ).reverse().forEach( groupRange => {
let quote = findQuote( groupRange.start );
if ( !quote ) {
const attributes: Record<string, unknown> = {};
attributes[ADMONITION_TYPE_ATTRIBUTE] = type;
quote = writer.createElement( 'aside', attributes);
writer.wrap( groupRange, quote );
} else if (quote.is("element")) {
this.editor.model.change((writer) => {
writer.setAttribute(ADMONITION_TYPE_ATTRIBUTE, type, quote as Element);
});
}
quotesToMerge.push( quote );
} );
// Merge subsequent <bQ> elements. Reverse the order again because this time we want to go through
// the <bQ> elements in the source order (due to how merge works it moves the right element's content
// to the first element and removes the right one. Since we may need to merge a couple of subsequent `<bQ>` elements
// we want to keep the reference to the first (furthest left) one.
quotesToMerge.reverse().reduce( ( currentQuote, nextQuote ) => {
if ( currentQuote.nextSibling == nextQuote ) {
writer.merge( writer.createPositionAfter( currentQuote ) );
return currentQuote;
}
return nextQuote;
} );
}
}
function findQuote( elementOrPosition: Element | Position ): Element | DocumentFragment | null {
return elementOrPosition.parent!.name == 'aside' ? elementOrPosition.parent : null;
}
/**
* Returns a minimal array of ranges containing groups of subsequent blocks.
*
* content: abcdefgh
* blocks: [ a, b, d, f, g, h ]
* output ranges: [ab]c[d]e[fgh]
*/
function getRangesOfBlockGroups( writer: Writer, blocks: Array<Element> ): Array<Range> {
let startPosition;
let i = 0;
const ranges = [];
while ( i < blocks.length ) {
const block = blocks[ i ];
const nextBlock = blocks[ i + 1 ];
if ( !startPosition ) {
startPosition = writer.createPositionBefore( block );
}
if ( !nextBlock || block.nextSibling != nextBlock ) {
ranges.push( writer.createRange( startPosition, writer.createPositionAfter( block ) ) );
startPosition = null;
}
i++;
}
return ranges;
}
/**
* Checks whether <bQ> can wrap the block.
*/
function checkCanBeQuoted( schema: Schema, block: Element ): boolean {
// TMP will be replaced with schema.checkWrap().
const isBQAllowed = schema.checkChild( block.parent as Element, 'aside' );
const isBlockAllowedInBQ = schema.checkChild( [ '$root', 'aside' ], block );
return isBQAllowed && isBlockAllowedInBQ;
}

View File

@ -0,0 +1,177 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module admonition/admonitionediting
*/
import { Delete, Enter, Plugin, ViewDocumentDeleteEvent, ViewDocumentEnterEvent } from 'ckeditor5';
import AdmonitionCommand, { AdmonitionType, ADMONITION_TYPES, DEFAULT_ADMONITION_TYPE, ADMONITION_TYPE_ATTRIBUTE } from './admonitioncommand.js';
/**
* The block quote editing.
*
* Introduces the `'admonition'` command and the `'aside'` model element.
*
* @extends module:core/plugin~Plugin
*/
export default class AdmonitionEditing extends Plugin {
/**
* @inheritDoc
*/
public static get pluginName() {
return 'AdmonitionEditing' as const;
}
/**
* @inheritDoc
*/
public static get requires() {
return [ Enter, Delete ] as const;
}
/**
* @inheritDoc
*/
public init(): void {
const editor = this.editor;
const schema = editor.model.schema;
editor.commands.add( 'admonition', new AdmonitionCommand( editor ) );
schema.register( 'aside', {
inheritAllFrom: '$container',
allowAttributes: ADMONITION_TYPE_ATTRIBUTE
} );
editor.conversion.for("upcast").elementToElement({
view: {
name: "aside",
classes: "admonition",
},
model: (viewElement, { writer }) => {
let type: AdmonitionType = DEFAULT_ADMONITION_TYPE;
for (const className of viewElement.getClassNames()) {
if (className !== "admonition" && (ADMONITION_TYPES as readonly string[]).includes(className)) {
type = className as AdmonitionType;
}
}
const attributes: Record<string, unknown> = {};
attributes[ADMONITION_TYPE_ATTRIBUTE] = type;
return writer.createElement("aside", attributes);
}
});
editor.conversion.for("downcast")
.elementToElement( {
model: 'aside',
view: "aside"
})
.attributeToAttribute({
model: ADMONITION_TYPE_ATTRIBUTE,
view: (value) => ({
key: "class",
value: [ "admonition", value as string ]
})
});
// Postfixer which cleans incorrect model states connected with block quotes.
editor.model.document.registerPostFixer( writer => {
const changes = editor.model.document.differ.getChanges();
for ( const entry of changes ) {
if ( entry.type == 'insert' ) {
const element = entry.position.nodeAfter;
if ( !element ) {
// We are inside a text node.
continue;
}
if ( element.is( 'element', 'aside' ) && element.isEmpty ) {
// Added an empty aside - remove it.
writer.remove( element );
return true;
} else if ( element.is( 'element', 'aside' ) && !schema.checkChild( entry.position, element ) ) {
// Added a aside in incorrect place. Unwrap it so the content inside is not lost.
writer.unwrap( element );
return true;
} else if ( element.is( 'element' ) ) {
// Just added an element. Check that all children meet the scheme rules.
const range = writer.createRangeIn( element );
for ( const child of range.getItems() ) {
if (
child.is( 'element', 'aside' ) &&
!schema.checkChild( writer.createPositionBefore( child ), child )
) {
writer.unwrap( child );
return true;
}
}
}
} else if ( entry.type == 'remove' ) {
const parent = entry.position.parent;
if ( parent.is( 'element', 'aside' ) && parent.isEmpty ) {
// Something got removed and now aside is empty. Remove the aside as well.
writer.remove( parent );
return true;
}
}
}
return false;
} );
const viewDocument = this.editor.editing.view.document;
const selection = editor.model.document.selection;
const admonitionCommand = editor.commands.get( 'admonition' );
if (!admonitionCommand) {
return;
}
// Overwrite default Enter key behavior.
// If Enter key is pressed with selection collapsed in empty block inside a quote, break the quote.
this.listenTo<ViewDocumentEnterEvent>( viewDocument, 'enter', ( evt, data ) => {
if ( !selection.isCollapsed || !admonitionCommand.value ) {
return;
}
const positionParent = selection.getLastPosition()!.parent;
if ( positionParent.isEmpty ) {
editor.execute( 'admonition' );
editor.editing.view.scrollToTheSelection();
data.preventDefault();
evt.stop();
}
}, { context: 'aside' } );
// Overwrite default Backspace key behavior.
// If Backspace key is pressed with selection collapsed in first empty block inside a quote, break the quote.
this.listenTo<ViewDocumentDeleteEvent>( viewDocument, 'delete', ( evt, data ) => {
if ( data.direction != 'backward' || !selection.isCollapsed || !admonitionCommand!.value ) {
return;
}
const positionParent = selection.getLastPosition()!.parent;
if ( positionParent.isEmpty && !positionParent.previousSibling ) {
editor.execute( 'admonition' );
editor.editing.view.scrollToTheSelection();
data.preventDefault();
evt.stop();
}
}, { context: 'aside' } );
}
}

View File

@ -0,0 +1,127 @@
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module admonition/admonitionui
*/
import { Plugin, addListToDropdown, createDropdown, ListDropdownItemDefinition, SplitButtonView, ViewModel } from 'ckeditor5';
import '../theme/blockquote.css';
import admonitionIcon from '../theme/icons/admonition.svg';
import { AdmonitionType } from './admonitioncommand.js';
import { Collection } from 'ckeditor5';
interface AdmonitionDefinition {
title: string;
}
export const ADMONITION_TYPES: Record<AdmonitionType, AdmonitionDefinition> = {
note: {
title: "Note"
},
tip: {
title: "Tip"
},
important: {
title: "Important"
},
caution: {
title: "Caution"
},
warning: {
title: "Warning"
}
};
/**
* The block quote UI plugin.
*
* It introduces the `'admonition'` button.
*
* @extends module:core/plugin~Plugin
*/
export default class AdmonitionUI extends Plugin {
/**
* @inheritDoc
*/
public static get pluginName() {
return 'AdmonitionUI' as const;
}
/**
* @inheritDoc
*/
public init(): void {
const editor = this.editor;
editor.ui.componentFactory.add( 'admonition', () => {
const buttonView = this._createButton();
return buttonView;
} );
}
/**
* Creates a button for admonition command to use either in toolbar or in menu bar.
*/
private _createButton() {
const editor = this.editor;
const locale = editor.locale;
const command = editor.commands.get( 'admonition' )!;
const dropdownView = createDropdown(locale, SplitButtonView);
const splitButtonView = dropdownView.buttonView;
const t = locale.t;
addListToDropdown(dropdownView, this._getDropdownItems())
// Button configuration.
splitButtonView.set( {
label: t( 'Admonition' ),
icon: admonitionIcon,
isToggleable: true,
tooltip: true
} );
splitButtonView.on("execute", () => {
editor.execute("admonition", { usePreviousChoice: true });
editor.editing.view.focus();
});
splitButtonView.bind( 'isOn' ).to( command, 'value', value => (!!value) as boolean);
// Dropdown configuration
dropdownView.bind( 'isEnabled' ).to( command, 'isEnabled' );
dropdownView.on("execute", evt => {
editor.execute("admonition", { forceValue: ( evt.source as any ).commandParam } );
editor.editing.view.focus();
});
return dropdownView;
}
private _getDropdownItems() {
const itemDefinitions = new Collection<ListDropdownItemDefinition>();
const command = this.editor.commands.get("admonition");
if (!command) {
return itemDefinitions;
}
for (const [ type, admonition ] of Object.entries(ADMONITION_TYPES)) {
const definition: ListDropdownItemDefinition = {
type: "button",
model: new ViewModel({
commandParam: type,
label: admonition.title,
role: 'menuitemradio',
withText: true
})
}
definition.model.bind("isOn").to(command, "value", currentType => currentType === type);
itemDefinitions.add(definition);
}
return itemDefinitions;
}
}

View File

@ -0,0 +1,17 @@
import AdmonitionCommand from './admonitioncommand.js';
import AdmonitionEditing from './admonitionediting.js';
import AdmonitionUI from './admonitionui.js';
import type { Admonition } from './index.js';
declare module 'ckeditor5' {
interface PluginsMap {
[ Admonition.pluginName ]: Admonition;
[ AdmonitionEditing.pluginName ]: AdmonitionEditing;
[ AdmonitionUI.pluginName ]: AdmonitionUI;
}
interface CommandsMap {
admonition: AdmonitionCommand;
}
}

View File

@ -0,0 +1,13 @@
import admonitionIcon from '../theme/icons/admonition.svg';
import './augmentation.js';
import "../theme/blockquote.css";
export { default as Admonition } from './admonition.js';
export { default as AdmonitionEditing } from './admonitionediting.js';
export { default as AdmonitionUI } from './admonitionui.js';
export { default as AdmonitionAutoformat } from './admonitionautoformat.js';
export type { default as AdmonitionCommand } from './admonitioncommand.js';
export const icons = {
admonitionIcon
};

Some files were not shown because too many files have changed in this diff Show More