ckeditor / ckeditor5

Powerful rich text editor framework with a modular architecture, modern integrations, and features like collaborative editing.
https://ckeditor.com/ckeditor-5
Other
9.58k stars 3.7k forks source link

ckeditor container shows Rich Text Editor text and doesn't wrap or size toolbar properly in angular #16647

Closed HelainaCurtis closed 4 months ago

HelainaCurtis commented 4 months ago

Reproduction steps

  1. …installed "ckeditor5-custom-build previously(worked as expected)
  2. …removed references of "ckeditor5-custom-build to try and run npm update
  3. … following ckeditor 5 builder instructions: ran npm install ckeditor and npm install ckeditor-angular
  4. placed css from instructions in styles.scss
  5. Modified our .ts file to set up config in ngafterviewinit
  6. At this point npm start was giving error for loader on importing ckeditors.css in the .ts file so placed '@ import 'ckeditor5/ckeditor5.css'; in component's scss file instead errorcss
  7. replaced one of our ckeditor fields with exact copy from instructions and the other replaced what was needed

Current behavior

This is what occurs on one of our ckeditor fields: All the toolbar pieces work on the text, but the box around the text is invisible and the toolbar is stacking very oddly. There is also always a Rich Text Editor text above the toolbar

stacking

Expected behavior

Expected our old functionality: old

Definition of Done

Relevant debug data

html of component:
 <forge-card
  class="project-info-card"
  outlined="true"
  *ngIf="{
    users: this.usersStore.users$ | async,
    project: this.projectDetailsService.project$ | async,
    projectTypes: this.projectTypesStore.projectTypesWithUnselectedOption$ | async,
    priorities: this.prioritiesStore.priorities$ | async,
    strategicGoals: this.strategicGoalsStore.strategicGoals$ | async,
    showSkeletonLoaders: showSkeletonLoaders$ | async,
    statusOptions: statusOptions$ | async
  } as state"
>
  <forge-scaffold>
    <ng-container *ngIf="state.showSkeletonLoaders; else body">
      <forge-skeleton list-item *ngFor="let iterator of [0, 1, 2, 3, 4, 5, 6, 7]"></forge-skeleton>
    </ng-container>
    <ng-template #body>
      <forge-expansion-panel #tab slot="body" [open]="true">
        <button
          slot="header"
          class="forge-expansion-panel__button expansion-panel-button"
          role="button"
          aria-expanded="true"
          aria-controls="expansion-panel-content"
        >
          <div class="title forge-typography--headline5">Project information</div>
          <forge-open-icon></forge-open-icon>
        </button>
        <div id="expansion-panel-content" role="group">
          <forge-divider class="header-body-divider"></forge-divider>
          <div slot="body" class="project-info-card-body">
            <form class="form-container" [formGroup]="formGroup">
              <div class="grid-container">
                <forge-text-field
                  class="text-field__name"
                  required="true"
                  [invalid]="
                    formGroup.controls['projectName'].errors?.maxlength ||
                    (formGroup.controls['projectName'].errors?.required && formGroup.controls['projectName'].touched)
                  "
                >
                  <label for="projectName">Project name</label>
                  <input
                    type="text"
                    #projectName
                    id="projectName"
                    tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit"
                    formControlName="projectName"
                  />
                  <span *ngIf="formGroup.controls['projectName'].errors?.maxlength" slot="helper-text"> Maximum length 80 characters.</span>
                  <span *ngIf="formGroup.controls['projectName'].errors?.required && formGroup.controls['projectName'].touched" slot="helper-text">
                    Project name is required.
                  </span>
                </forge-text-field>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Status" formControlName="status" required="true">
                  <forge-option *ngFor="let status of state.statusOptions" [value]="status.value">{{ status.label }}</forge-option>
                </forge-select>

                <forge-text-field class="text-field__name text-field__helper-text" [invalid]="formGroup.controls['location'].errors?.maxlength">
                  <label for="location">Location</label>
                  <input type="text" id="location" tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" formControlName="location" />
                  <forge-icon-button slot="addon-end">
                    <button
                      type="button"
                      aria-label="Retrieve coordinates"
                      (click)="retrieveCoordinatesClicked()"
                      [disabled]="formGroup.controls['location'].errors !== null || formGroup.controls['location'].value === originalLocationValue"
                    >
                      <forge-icon name="magnify"></forge-icon>
                    </button>
                  </forge-icon-button>
                  <span *ngIf="formGroup.controls['location'].errors?.maxlength" slot="helper-text"> Maximum length 80 characters.</span>
                  <span *ngIf="!formGroup.controls['location'].errors?.maxlength" slot="helper-text">Enter the project address or location description.</span>
                </forge-text-field>

                <forge-text-field class="text-field__helper-text">
                  <label for="project-number">Project number</label>
                  <input type="text" id="project-number" value="{{ state.project.budgetProjectNumber }}" disabled="true" />
                  <span slot="helper-text">Project number is automatically assigned.</span>
                </forge-text-field>

                <forge-autocomplete [filter]="departmentFilter" formControlName="department">
                  <forge-text-field>
                    <input class="department truncate-text" type="text" tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" />
                    <label for="department">Department</label>
                    <forge-icon slot="trailing" name="arrow_drop_down" data-forge-dropdown-icon></forge-icon>
                    <forge-icon class="infoIcon" slot="addon-end" name="info_outline" id="departmentInfo"></forge-icon>
                    <forge-tooltip target="#departmentInfo" position="bottom">{{ formGroup.get('department').value?.description }}</forge-tooltip>
                  </forge-text-field>
                </forge-autocomplete>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Project manager" formControlName="projectManager">
                  <forge-option *ngFor="let user of state.users" [value]="user">{{ user.fullName }}</forge-option>
                </forge-select>

                <forge-text-field [invalid]="formGroup.controls['externalProjectNumber'].errors?.maxlength">
                  <label for="external-project-number">External project number</label>
                  <input
                    type="text"
                    id="external-project-number"
                    tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit"
                    formControlName="externalProjectNumber"
                  />
                  <span *ngIf="formGroup.controls['externalProjectNumber'].errors?.maxlength" slot="helper-text">Maximum length is 80 characters.</span>
                </forge-text-field>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Project type" formControlName="projectType">
                  <forge-option *ngFor="let projectType of state.projectTypes" [value]="projectType">{{ projectType.name }}</forge-option>
                  <forge-icon class="infoIcon" slot="addon-end" name="info_outline" id="projectTypeInfo"></forge-icon>
                  <forge-tooltip target="#projectTypeInfo" position="bottom">{{ formGroup.get('projectType').value.description }}</forge-tooltip>
                </forge-select>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Strategic goal" formControlName="strategicGoal">
                  <forge-icon class="infoIcon" slot="addon-end" name="info_outline" id="goalInfo"></forge-icon>
                  <forge-tooltip target="#goalInfo" position="bottom">{{ formGroup.get('strategicGoal').value?.description }}</forge-tooltip>
                  <forge-option *ngFor="let strategicGoal of state.strategicGoals" [value]="strategicGoal">{{ strategicGoal.name }}</forge-option>
                </forge-select>

                <forge-select tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" label="Priority" formControlName="priority">
                  <forge-icon class="infoIcon" slot="addon-end" name="info_outline" id="priorityInfo"></forge-icon>
                  <forge-tooltip target="#priorityInfo" position="bottom">{{ formGroup.get('priority').value?.description }}</forge-tooltip>
                  <forge-option *ngFor="let priority of state.priorities" [value]="priority">{{ priority.name }}</forge-option>
                </forge-select>

                <forge-date-picker formControlName="projectedStartDate" value-mode="iso-string" id="projectedStartDate" #projectedStartDate>
                  <forge-text-field [invalid]="formGroup.get('projectedStartDate').value > formGroup.get('projectedEndDate').value">
                    <label for="projectedStartDate" slot="label">Projected start date</label>
                    <input tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" type="text" id="projectedStartDate" placeholder="mm/dd/yyyy" />
                    <span *ngIf="formGroup.get('projectedStartDate').value > formGroup.get('projectedEndDate').value" slot="helper-text"
                      >The start date must be before the end date.</span
                    >
                  </forge-text-field>
                </forge-date-picker>

                <forge-date-picker formControlName="projectedEndDate" value-mode="iso-string" id="projectedEndDate" #projectedStartDate>
                  <forge-text-field [invalid]="formGroup.get('projectedEndDate').value < formGroup.get('projectedStartDate').value">
                    <label for="projectedEndDate" slot="label">Projected end date</label>
                    <input tpbDisable="Permissions.CapitalBudget.Projects.Projects.Edit" type="text" id="projectedEndDate" placeholder="mm/dd/yyyy" />
                    <span *ngIf="formGroup.get('projectedEndDate').value < formGroup.get('projectedStartDate').value" slot="helper-text"
                      >The end date must be after the start date.</span
                    >
                  </forge-text-field>
                </forge-date-picker>
              </div>
              <div class="ckEditor">
                <label class="ckEditor__label" for="description">Description</label>
                <div class="editor-container editor-container_classic-editor" #editorContainerElement>
                  <div class="editor-container__editor">
                    <div #editorElement>
                      <ckeditor
                        *ngIf="isLayoutReady"
                        class="ckEditor__input"
                        id="description"
                        formControlName="description"
                        [editor]="editor"
                        [config]="editorConfig"
                      ></ckeditor>
                    </div>
                  </div>
                </div>
              </div>

              <div class="ckEditor">
                <label class="ckEditor__label" for="justification">Justification</label>
                <ckeditor
                  *ngIf="isLayoutReady"
                  class="ckEditor__input"
                  id="justification"
                  formControlName="justification"
                  [editor]="editor"
                  [config]="editorConfig"
                ></ckeditor>
              </div>
            </form>
          </div>
        </div>
      </forge-expansion-panel>
    </ng-template>
  </forge-scaffold>
</forge-card>

scss file of component: .expansion-panel-button {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
}

header-body-divider {
  padding-bottom: 16px;
}

#expansion-panel-content > div[slot='body'] {
  padding: 16px;
  display: grid;
  gap: 16px;
  padding-top: 16px;
}

.grid-container {
  display: grid;
  grid-template-columns: repeat(3, minmax(20vw, 33vw));
  grid-template-rows: auto;
  gap: 32px 16px;
}

forge-card.project-info-card {
  --forge-card-padding: 0px;
}

.text-field__name {
  grid-column: span 2;
}

.text-field__helper-text {
  margin-bottom: -24px;
}

.ckEditor {
  padding-top: 12px;
}

.ckEditor__label {
  color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.54));
}

.ckEditor__input {
  width: 100%;
}

.infoIcon {
  padding: 1.5px;
}

:host ::ng-deep .ck-editor__editable_inline {
  min-height: 112px !important;
  max-height: 180px;
  overflow: auto;
}

.truncate-text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
@import 'ckeditor5/ckeditor5.css';

ts of component:
 import {
  AfterViewInit,
  Component,
  ElementRef,
  EventEmitter,
  Input,
  OnDestroy,
  OnInit,
  Output,
  ViewChild,
  ChangeDetectorRef,
} from '@angular/core';
import { FormGroup } from '@angular/forms';
import { Observable, filter, lastValueFrom, map, of, startWith, take } from 'rxjs';
import { BusyState } from 'tipe-core-components';
import { IAutocompleteOption, IOption } from '@tylertech/forge';
import { DepartmentsStore } from 'src/app/store/departments/departments-store';
import { ProjectDetailsService } from 'src/app/store/project-details/project-details.service';
import { ProjectTypesStore } from 'src/app/store/project-types/project-types-store.module';
import { UsersStore } from 'src/app/store/users/users-store';
import { PrioritiesStore } from 'src/app/store/priorities/priorities-store.module';
import { StrategicGoalsStore } from 'src/app/store/strategic-goals/strategic-goals-store.module';
import { Subject, takeUntil } from 'rxjs';
import { IDepartments } from 'src/app/store/models/departments.model';
import { STATUS_LABEL } from 'src/app/store/models/project.models';
import { ActivatedRoute } from '@angular/router';
import { TableUtils } from 'budget-shared-components';
import {
  ClassicEditor,
  AccessibilityHelp,
  Autosave,
  Bold,
  Essentials,
  FontBackgroundColor,
  FontColor,
  FontFamily,
  FontSize,
  Heading,
  Indent,
  IndentBlock,
  Italic,
  List,
  Paragraph,
  SelectAll,
  Undo,
  EditorConfig,
} from 'ckeditor5';

@Component({
  selector: 'project-info',
  templateUrl: './project-info.component.html',
  styleUrls: ['./project-info.component.scss'],
})
export class ProjectInfoComponent implements OnDestroy, OnInit, AfterViewInit {
  @Input() formGroup: FormGroup;
  @Output() retrieveCoordinates: EventEmitter<void> = new EventEmitter();
  @ViewChild('projectName') projectNameInput: ElementRef;
  public showSkeletonLoaders$: Observable<boolean>;
  public isLayoutReady = false;
  public editor = ClassicEditor;
  public editorConfig: EditorConfig = {};

  private _unsubscribe$ = new Subject<void>();
  public departmentFilter = (filter: string, selectedAccountId: number): Promise<IAutocompleteOption<IDepartments>[]> =>
    this.checkDepartmentsLoaded(filter, selectedAccountId);
  public departments: IDepartments[];
  public statusOptions: IOption[] = [];
  public statusOptions$: Observable<IOption[]>;
  public originalLocationValue: string;

  constructor(
    public usersStore: UsersStore,
    public prioritiesStore: PrioritiesStore,
    public strategicGoalsStore: StrategicGoalsStore,
    public projectDetailsService: ProjectDetailsService,
    public projectTypesStore: ProjectTypesStore,
    public departmentsStore: DepartmentsStore,
    private _activatedRoute: ActivatedRoute,
    private _changeDetector: ChangeDetectorRef
  ) {}

  ngOnInit(): void {
    this._setupSkeletonLoaders();
    this.loadDetails();
    this._setupStatusLabels();
    this._setupOriginalLocationValue();
  }

  ngOnDestroy(): void {
    this._unsubscribe$.next();
    this._unsubscribe$.complete();
  }

  ngAfterViewInit(): void {
    this.projectNameInput.nativeElement.focus();
    this.editorConfig = {
      toolbar: {
        items: [
          'undo',
          'redo',
          '|',
          'selectAll',
          '|',
          'heading',
          '|',
          'fontSize',
          'fontFamily',
          'fontColor',
          'fontBackgroundColor',
          '|',
          'bold',
          'italic',
          '|',
          'bulletedList',
          'numberedList',
          'indent',
          'outdent',
          '|',
          'accessibilityHelp',
        ],
        shouldNotGroupWhenFull: false,
      },
      plugins: [
        AccessibilityHelp,
        Autosave,
        Bold,
        Essentials,
        FontBackgroundColor,
        FontColor,
        FontFamily,
        FontSize,
        Heading,
        Indent,
        IndentBlock,
        Italic,
        List,
        Paragraph,
        SelectAll,
        Undo,
      ],
      fontFamily: {
        supportAllValues: true,
      },
      fontSize: {
        options: [10, 12, 14, 'default', 18, 20, 22],
        supportAllValues: true,
      },
      heading: {
        options: [
          {
            model: 'paragraph',
            title: 'Paragraph',
            class: 'ck-heading_paragraph',
          },
          {
            model: 'heading1',
            view: 'h1',
            title: 'Heading 1',
            class: 'ck-heading_heading1',
          },
          {
            model: 'heading2',
            view: 'h2',
            title: 'Heading 2',
            class: 'ck-heading_heading2',
          },
          {
            model: 'heading3',
            view: 'h3',
            title: 'Heading 3',
            class: 'ck-heading_heading3',
          },
          {
            model: 'heading4',
            view: 'h4',
            title: 'Heading 4',
            class: 'ck-heading_heading4',
          },
          {
            model: 'heading5',
            view: 'h5',
            title: 'Heading 5',
            class: 'ck-heading_heading5',
          },
          {
            model: 'heading6',
            view: 'h6',
            title: 'Heading 6',
            class: 'ck-heading_heading6',
          },
        ],
      },
    };

    this.isLayoutReady = true;
    this._changeDetector.detectChanges();
  }

  retrieveCoordinatesClicked(): void {
    this.retrieveCoordinates.emit(null);
  }

  private _setupOriginalLocationValue(): void {
    this.projectDetailsService.project$
      .pipe(takeUntil(this._unsubscribe$))
      .subscribe((project) => (this.originalLocationValue = project.location));
  }

  private _setupStatusLabels(): void {
    STATUS_LABEL.forEach((value, key) => {
      this.statusOptions.push({
        value: STATUS_LABEL.get(key),
        label: value,
      });
    });
    this.statusOptions$ = of(this.statusOptions);
  }

  private _setupSkeletonLoaders(): void {
    const projectId = Number(this._activatedRoute.snapshot.paramMap.get('projectId'));
    this.showSkeletonLoaders$ = this.projectDetailsService.busyState$.pipe(
      filter((s) => s === BusyState.NONE),
      take(1),
      map(() => false),
      startWith(true)
    );
  }

  loadDetails(): void {
    this.departmentsStore.departments$
      .pipe(takeUntil(this._unsubscribe$))
      .subscribe((departments) => (this.departments = departments));
  }

  getLookupForDepartmentId(typeInFilter: string, selectedDepartmentId: number): IAutocompleteOption<IDepartments>[] {
    return this.departments
      .filter(
        (department) =>
          (selectedDepartmentId > 0 && department.id === selectedDepartmentId) ||
          department.name.toString().toLowerCase().includes(typeInFilter.toLowerCase())
      )
      .map((department) => {
        const option: any = {
          label: department.name,
          value: department,
        };
        return option;
      })
      .sort((option1, option2) =>
        TableUtils.comparator(option1.value.name, option2.value.name, 'string')
      ) as IAutocompleteOption<IDepartments>[];
  }

  async checkDepartmentsLoaded(
    typeInFilter: string,
    selectedDepartmentId: number
  ): Promise<IAutocompleteOption<IDepartments>[]> {
    const result: Observable<IAutocompleteOption<IDepartments>[]> = new Observable((subscriber) =>
      this.departmentsStore.busyState$
        .pipe(
          filter((busyState) => busyState === BusyState.NONE),
          take(1)
        )
        .subscribe(() => {
          subscriber.next(this.getLookupForDepartmentId(typeInFilter, selectedDepartmentId));
          subscriber.complete();
        })
    );

    return await lastValueFrom(result);
  }
}

Other details

We use custom schema on our html so you will see other elements in the html that are unfamiliar. We use ngrx and I've left the references, but none of the functionality is the issue, code is included and left as is for transparency.

User agent

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0

Witoso commented 4 months ago

Hi! The new installation methods expect that the CSS will be loaded separately. The screenshot suggests that the stylesheets were not loaded.

There may be two different solutions here:

  1. giving error for loader on importing ckeditors.css,
    1. add a webpack CSS loader, so that the webpack correctly loads the CSS.
  2. '@ import 'ckeditor5/ckeditor5.css'; in component's scss file instead
    1. it's possible that SCSS cannot resolve this path, the exact file is under node_modules/ckeditor5/dist/ckeditor.css

Please investigate, and pick the option that fits your setup. The solution might also depend on your Angular setup or version. Let me know if it helped.

HelainaCurtis commented 4 months ago

Thanks so much for the quick response! I tried the 2nd solution but it did not work. I tried the 1st solution, installed css-loader and style-loader. Our project is angular 17, and does not use a webpack.config.js. So I tried to use the inline style specified here: https://webpack.js.org/concepts/loaders/#inline Thus, adding this in the .ts file where theckeditor.css import is suggested import '!style-loader!css-loader!/home/helaina/pathToRepo/App/client/node_modules/ckeditor5/dist/ckeditor5.css'; I still need to change the path to not use my own computers path but this made the ckeditors load correctly at least:

fixed
HelainaCurtis commented 4 months ago

Got the path working, Thanks for the directions and help