Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
"src/vendor/jquery-ui-1.14.1/jquery-ui.css",
"src/vendor/jquery-ui-1.14.1/jquery-ui.structure.css",
"src/vendor/jquery-ui-1.14.1/jquery-ui.theme.css",
"node_modules/flatpickr/dist/flatpickr.min.css"
"node_modules/flatpickr/dist/flatpickr.min.css",
"src/vendor/ckeditor/ckeditor.css"
],
"stylePreprocessorOptions": {
"includePaths": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,18 @@ export class CKEditorSetupService {
* Load the ckeditor asset
*/
private async load():Promise<void> {
// untyped module cannot be dynamically imported
// untyped modules cannot be dynamically imported
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, this typing can/should be fixed eventually: https://community.openproject.org/wp/72830

(I started work on opf/commonmark-ckeditor-build#109 a while back)

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await import(/* webpackChunkName: "ckeditor" */ 'core-vendor/ckeditor/ckeditor');
const loadEditorScript = import(/* webpackChunkName: "ckeditor" */ 'core-vendor/ckeditor/ckeditor');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick, but /* webpackChunkName: "ckeditor" */ can be removed now. esbuild ignores.


const promises = [loadEditorScript];

if (I18n.locale !== 'en') {
await this.loadLocale();
promises.push(this.loadLocale());
}

await Promise.all(promises);
}

private async loadLocale():Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Injectable } from '@angular/core';
import type CodeMirrorStatic from 'codemirror';

type CodeMirrorType = typeof CodeMirrorStatic;

@Injectable({ providedIn: 'root' })
export class CodeMirrorLoaderService {
private codeMirrorPromise:Promise<CodeMirrorType>|undefined;

private loadedModes = new Set<string>();
private missingModes = new Set<string>();
private modePromises = new Map<string, Promise<boolean>>();

public async loadCore():Promise<CodeMirrorType> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.codeMirrorPromise ??= import(/* webpackChunkName: "codemirror" */ '../../../../../../../node_modules/codemirror/lib/codemirror.js')
.then((imported:{ default:CodeMirrorType }) => imported.default);

return this.codeMirrorPromise;
}

public async ensureModeLoaded(language:string):Promise<boolean> {
if (!language || language === 'text') {
return true;
}

const normalizedLanguage = language.toLowerCase();

if (this.loadedModes.has(normalizedLanguage)) {
return true;
}

if (this.missingModes.has(normalizedLanguage)) {
return false;
}

if (!this.modePromises.has(normalizedLanguage)) {
this.modePromises.set(normalizedLanguage, this.loadMode(normalizedLanguage));
}

return this.modePromises.get(normalizedLanguage)!;
}

private async loadMode(language:string):Promise<boolean> {
await this.loadCore();

try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await import(
/* webpackChunkName: "codemirror-mode" */ `../../../../../../../node_modules/codemirror/mode/${language}/${language}.js`
);

this.loadedModes.add(language);
return true;
} catch {
this.missingModes.add(language);
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
// See COPYRIGHT and LICENSE files for more details.
//++

import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild, inject } from '@angular/core';
import type { Editor as CodeMirrorEditor } from 'codemirror';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { ConfigurationService } from 'core-app/core/config/configuration.service';
Expand All @@ -36,12 +37,11 @@ import {
ICKEditorWatchdog,
} from 'core-app/shared/components/editor/components/ckeditor/ckeditor.types';
import { CKEditorSetupService } from 'core-app/shared/components/editor/components/ckeditor/ckeditor-setup.service';
import { CodeMirrorLoaderService } from 'core-app/shared/components/editor/components/ckeditor/codemirror-loader.service';
import { KeyCodes } from 'core-app/shared/helpers/keycodes';
import { debugLog } from 'core-app/shared/helpers/debug_output';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';

declare module 'codemirror';

@Component({
selector: 'op-ckeditor',
templateUrl: './op-ckeditor.html',
Expand Down Expand Up @@ -102,12 +102,19 @@ export class OpCkeditorComponent extends UntilDestroyedMixin implements OnInit,

private _content = '';

private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly Notifications = inject(ToastService);
private readonly I18n = inject(I18nService);
private readonly configurationService = inject(ConfigurationService);
private readonly ckEditorSetup = inject(CKEditorSetupService);
private readonly codeMirrorLoader = inject(CodeMirrorLoaderService);

public text = {
errorTitle: this.I18n.t('js.editor.ckeditor_error'),
};

// Codemirror instance, initialized lazily when running source mode
public codeMirrorInstance:undefined|any;
public codeMirrorInstance:CodeMirrorEditor|null = null;

// Debounce change listener for both CKE and codemirror
// to read back changes as they happen
Expand All @@ -120,16 +127,6 @@ export class OpCkeditorComponent extends UntilDestroyedMixin implements OnInit,
{ leading: true },
);

constructor(
private readonly elementRef:ElementRef<HTMLElement>,
private readonly Notifications:ToastService,
private readonly I18n:I18nService,
private readonly configurationService:ConfigurationService,
private readonly ckEditorSetup:CKEditorSetupService,
) {
super();
}

/**
* Get the current live data from CKEditor. This may raise in cases
* the data cannot be loaded (MS Edge!)
Expand All @@ -138,8 +135,7 @@ export class OpCkeditorComponent extends UntilDestroyedMixin implements OnInit,
let content:string;

if (this.manualMode) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
content = this.codeMirrorInstance.getValue() as string;
content = this.codeMirrorInstance!.getValue();
} else {
content = this.ckEditorInstance.getData({ trim: false });
}
Expand Down Expand Up @@ -335,25 +331,23 @@ export class OpCkeditorComponent extends UntilDestroyedMixin implements OnInit,
const current = this.getRawData();
const cmMode = 'gfm';

void Promise
.all([
import('codemirror'),
import(/* webpackChunkName: "codemirror-mode" */ `../../../../../../../node_modules/codemirror/mode/${cmMode}/${cmMode}.js`),
])
.then((imported:any[]) => {
const CodeMirror = imported[0].default;
void this.codeMirrorLoader
.ensureModeLoaded(cmMode)
.then((modeLoaded) => modeLoaded ? cmMode : '')
.then(async (resolvedMode) => {
const CodeMirror = await this.codeMirrorLoader.loadCore();
this.codeMirrorInstance = CodeMirror(
this.elementRef.nativeElement.querySelector('.ck-editor__source'),
this.elementRef.nativeElement.querySelector<HTMLElement>('.ck-editor__source')!,
{
lineNumbers: true,
smartIndent: true,
value: current,
mode: '',
mode: resolvedMode,
},
);

this.codeMirrorInstance.on('change', this.debouncedEmitter);
setTimeout(() => this.codeMirrorInstance.refresh(), 100);
setTimeout(() => this.codeMirrorInstance!.refresh(), 100);
this.manualMode = true;
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@
//++

import {
AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, ViewChild,
AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, ViewChild, inject,
} from '@angular/core';
import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types';
import { OpModalComponent } from 'core-app/shared/components/modal/modal.component';
import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { CodeMirrorLoaderService } from 'core-app/shared/components/editor/components/ckeditor/codemirror-loader.service';
import type { Editor as CodeMirrorEditor } from 'codemirror';

@Component({
templateUrl: './code-block-macro.modal.html',
Expand All @@ -56,12 +58,20 @@ export class CodeBlockMacroModalComponent extends OpModalComponent implements Af
public content:string;

// Codemirror instance
public codeMirrorInstance:undefined|any;
public codeMirrorInstance:CodeMirrorEditor|undefined;

private pendingMode:string|undefined;

public debouncedLanguageLoader = _.debounce(() => this.loadLanguageAsMode(this.language), 300);

@ViewChild('codeMirrorPane', { static: true }) codeMirrorPane:ElementRef;

readonly elementRef = inject(ElementRef);
public locals = inject(OpModalLocalsToken) as OpModalLocalsMap;
readonly cdRef = inject(ChangeDetectorRef);
readonly I18n = inject(I18nService);
readonly codeMirrorLoader = inject(CodeMirrorLoaderService);

public text:any = {
title: this.I18n.t('js.editor.macro.code_block.title'),
language: this.I18n.t('js.editor.macro.code_block.language'),
Expand All @@ -71,13 +81,14 @@ export class CodeBlockMacroModalComponent extends OpModalComponent implements Af
close_popup: this.I18n.t('js.close_popup_title'),
};

constructor(readonly elementRef:ElementRef,
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef,
readonly I18n:I18nService) {
super(locals, cdRef, elementRef);
this.languageClass = locals.languageClass || 'language-text';
this.content = locals.content;
constructor() {
super(
inject(OpModalLocalsToken) as OpModalLocalsMap,
inject(ChangeDetectorRef),
inject(ElementRef),
);
this.languageClass = (this.locals.languageClass as string | undefined) ?? 'language-text';
this.content = this.locals.content as string;

const match = /language-(\w+)/.exec(this.languageClass);
if (match) {
Expand All @@ -88,7 +99,7 @@ export class CodeBlockMacroModalComponent extends OpModalComponent implements Af
}

public applyAndClose(evt:Event):void {
this.content = this.codeMirrorInstance.getValue();
this.content = this.codeMirrorInstance!.getValue();
const lang = this.language || 'text';
this.languageClass = `language-${lang}`;

Expand All @@ -97,10 +108,9 @@ export class CodeBlockMacroModalComponent extends OpModalComponent implements Af
}

ngAfterViewInit():void {
import('codemirror').then((imported:any) => {
const CodeMirror = imported.default;
void this.codeMirrorLoader.loadCore().then((CodeMirror) => {
this.codeMirrorInstance = CodeMirror.fromTextArea(
this.codeMirrorPane.nativeElement,
this.codeMirrorPane.nativeElement as HTMLTextAreaElement,
{
lineNumbers: true,
smartIndent: true,
Expand All @@ -109,6 +119,10 @@ export class CodeBlockMacroModalComponent extends OpModalComponent implements Af
mode: '',
},
);
if (this.pendingMode !== undefined) {
this.updateCodeMirrorMode(this.pendingMode);
this.pendingMode = undefined;
}
});
}

Expand All @@ -127,19 +141,21 @@ export class CodeBlockMacroModalComponent extends OpModalComponent implements Af
return this.updateCodeMirrorMode('');
}

import(/* webpackChunkName: "codemirror-mode" */ `../../../../../../../node_modules/codemirror/mode/${language}/${language}.js`)
.then(() => {
this.updateCodeMirrorMode(language);
})
.catch((e) => {
console.error(`Failed to load language ${language}: ${e}`);
this.updateCodeMirrorMode('');
void this.codeMirrorLoader
.ensureModeLoaded(language)
.then((modeLoaded) => {
this.updateCodeMirrorMode(modeLoaded ? language : '');
});
}

updateCodeMirrorMode(newLanguage:string) {
const editor = this.codeMirrorInstance;
editor?.setOption('mode', newLanguage);
if (!this.codeMirrorInstance) {
this.pendingMode = newLanguage;
return;
}

this.codeMirrorInstance.setOption('mode', newLanguage);
this.codeMirrorInstance.refresh();
}

updateLanguage(newValue?:string) {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/global_styles/content/editor/_ckeditor.sass
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ opce-ckeditor-augmented-textarea .op-ckeditor--attachments
min-height: 100px
padding: 10px

// Keep editor content typography aligned with surrounding OpenProject styles.
--ck-content-font-family: var(--body-font-family)
--ck-content-font-size: var(--body-font-size)

// Specific overrides for ck contenteditable
.ck-content

Expand Down Expand Up @@ -70,6 +74,10 @@ opce-ckeditor-augmented-textarea .op-ckeditor--attachments
transform: translateX( -15px )
z-index: 1000 !important

// Force forms of ck to be block
// as our forms are inline by default
.ck.ck-form
display: block

// Override fixed position of toolbar
// Otherwise the toolbar will 'disappear' behind the topmenu
Expand Down
Loading
Loading