ldc-gen-game-web/resources/ts/components/CkEditorComponent.vue
Jérémy Munsch a27f0bf4d8
feat(ckeditor): integrate CKEditor 5 with custom plugins and styles
- Added CKEditor 5 integration with custom plugins for enhanced functionality.
- Implemented a unique ID generator for component instances.
- Created a dedicated SCSS file for CKEditor styles to ensure proper styling.
- Updated the CkEditorComponent to utilize the new setup and improve data handling.
2026-07-23 16:46:41 +02:00

354 lines
8.9 KiB
Vue

<template>
<div class="ck-editor-component">
<div ref="wordCounter" />
<div ref="ckeditor" />
<input
ref="hiddenInput"
:id="id"
class="form-control"
type="hidden"
:name="name"
:value="text"
:required="required"
>
</div>
</template>
<script lang="ts" setup>
import { ClassicEditor, Editor, type EditorConfig } from "ckeditor5";
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
shallowRef,
useAttrs,
} from "vue";
import { builtinPlugins, frTranslations } from "../modules/ckeditor";
import { uniqueId } from "../modules/uniqueid";
import "ckeditor5/ckeditor5.css";
defineOptions({ name: "CkEditorComponent", });
// * EMITS
const emits = defineEmits<{
input: [string: string],
}>();
// * ATTRIBUTES
const attrs = useAttrs();
// * REFS
const ckeditor = ref<HTMLDivElement | null>(null);
const wordCounter = ref<HTMLDivElement | null>(null);
const hiddenInput = ref<HTMLInputElement | null>(null);
const editorInstance = shallowRef<Editor | null>(null);
// * PROPS
const props = defineProps({
id: {
type: String,
default: () => uniqueId(),
},
name: {
type: String,
default: ""
},
dbtext: {
type: String,
default: ""
},
required: {
type: Boolean,
default: false
},
});
// * DATA
const id = ref<string>(props.id ?? uniqueId());
const name = ref<string>(props.name ?? "");
const text = ref<string>(props.dbtext ?? "");
const required = ref<boolean>(props.required ?? false);
/** Counter parameters. */
const counterParameters = reactive<{
words: number,
chars: number,
}>({
words: 0,
chars: 0,
});
// * COMPUTED
const isUsedWithProps = computed<boolean>(() => attrs.json === undefined);
// * LIFECYCLE
onMounted(() => {
const json = String(attrs.json ?? "{}"),
data = JSON.parse(json);
if (!isUsedWithProps.value) {
if (data.id) id.value = String(data.id);
name.value = String(data.name);
text.value = String(data.text);
if (data.required) required.value = Boolean(data.required);
void makeSuperBuild();
}
hiddenInput.value?.addEventListener("ss:ck:set-data", onExternalSetData as EventListener);
});
onBeforeUnmount(() => {
hiddenInput.value?.removeEventListener("ss:ck:set-data", onExternalSetData as EventListener);
});
// * METHODS
async function makeSuperBuild(): Promise<void> {
const editorConfig: EditorConfig = {
plugins: builtinPlugins,
translations: [frTranslations],
wordCount: {
container: wordCounter.value as HTMLElement,
onUpdate: (stats: { characters: number; words: number }) => {
counterParameters.chars = stats.characters;
counterParameters.words = stats.words;
},
},
link: {
// Automatically add target="_blank" and rel="noopener noreferrer" to all external links.
addTargetToExternalLinks: true,
// Let the users control the "download" attribute of each link.
decorators: {
openInNewTab: {
mode: "manual",
label: "Ouvrir dans un nouvel onglet",
attributes: { target: "_blank", }
}
}
},
typing: {
transformations: {
include: [],
extra: [
{ from: ":)", to: "😊" },
{ from: ":-P", to: "😛" },
{ from: "8-)", to: "😎" },
{ from: ";-)", to: "😉" },
{ from: ":-D", to: "😄" },
{ from: ":twisted:", to: "😈" },
{ from: ":mrgreen:", to: "😁" },
{ from: ":lol:", to: "😆" },
{ from: ":roll:", to: "🙄" },
{ from: ":-?", to: "😕" },
{ from: ":cry:", to: "😢" },
{ from: ":-o", to: "😮" },
{ from: ":evil:", to: "😡" },
{ from: ":-|", to: "😐" },
{ from: ":oops:", to: "😳" },
{ from: ":-x", to: "😠" },
{ from: "8-O", to: "😲" },
{ from: ":-(", to: "😧" },
{ from: ":arrow:", to: "➡" },
{ from: ":idea:", to: "💡" },
],
},
},
mediaEmbed: {
previewsInData: false,
removeProviders: [
"dailymotion",
"spotify",
"youtube",
"vimeo",
"instagram",
"twitter",
"googleMaps",
"flickr",
"facebook",
],
// // see https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-media-embed/src/mediaembedediting.js
// providers: [
// {
// // display local videos in oembed tags
// name: "localVideo",
// url: /^(\/(?:content|storage|uploads)\/.*)/,
// html: (match: string[]) => {
// const path = match[1];
// return (
// "<video controls allowfullscreen style=\"max-width: 90%; margin: 0 auto; display: block;\">" +
// `<source src="${path}" type="video/mp4">` +
// `<p>Votre navigateur ne prend pas en charge les vidéos HTML5. Voici <a href="${path}">un lien pour télécharger la vidéo</a>.</p>` +
// "</video>"
// );
// },
// },
// ],
},
toolbar: {
items: [
"heading",
"|",
"findAndReplace",
"|",
"bold",
"italic",
"underline",
"strikethrough",
"link",
"bulletedList",
"numberedList",
"code",
"codeBlock",
"|",
"outdent",
"indent",
"alignment",
"|",
"undo",
"redo",
"selectAll",
"|",
"blockQuote",
"|",
"insertTable",
"tableColumn",
"tableRow",
"mergeTableCells",
"|",
"highlight",
"removeHighlight",
"fontColor",
"fontBackgroundColor",
"fontFamily",
"fontSize",
"horizontalLine",
"specialCharacters",
"|",
"removeFormat",
"|",
"sourceEditing",
],
},
table: {
contentToolbar: [
"tableColumn",
"tableRow",
"mergeTableCells",
"tableProperties",
"tableCellProperties",
"toggleTableCaption",
],
// Configuration of the TableProperties plugin.
tableProperties: {},
// Configuration of the TableCellProperties plugin.
tableCellProperties: {},
},
language: "fr",
image: {
toolbar: [
"toggleImageCaption",
"imageTextAlternative",
"imageStyle:inline",
"imageStyle:alignLeft",
"imageStyle:alignRight",
"imageStyle:alignCenter",
"imageStyle:side",
"imageResize",
"linkImage",
],
},
};
ClassicEditor.create(ckeditor.value as HTMLElement, { ...editorConfig, licenseKey: "GPL" })
.then((editor: Editor) => {
let timer: ReturnType<typeof setTimeout> | null = null;
const updateContent = () => {
const data = editor.getData();
text.value = data;
emits("input", data);
timer = null;
};
// Display all toolbar elements
// console.log(Array.from(editor.ui.componentFactory.names()));
editorInstance.value = editor;
ckeditor.value = Object.freeze(editor) as never;
editor.setData(text.value);
editor.model.document.on("change:data", () => {
if (timer) {
clearTimeout(timer);
}
// * Keep track of internal editor
timer = setTimeout(updateContent, 1000);
});
})
.catch((error: unknown) => {
console.error("Oops, something went wrong!");
console.error(
"Please, report the following error on https://github.com/ckeditor/ckeditor5/issues with the build id and the error stack trace:"
);
console.warn("Build id: x0tjc6omqq4d-iydt761tuj51");
console.error(error);
});
}
function onExternalSetData(event: Event): void {
const customEvent = event as CustomEvent<{ data?: string }>;
const payload = customEvent.detail?.data;
if (typeof payload !== "string") {
return;
}
text.value = payload;
if (hiddenInput.value) {
hiddenInput.value.value = payload;
}
if (editorInstance.value) {
editorInstance.value.setData(payload);
}
}
</script>
<style lang="scss" scoped>
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
.ck-editor-component {
.ck-rounded-corners {
border-radius: 0 0 0.375rem 0.375rem !important;
}
.ck-word-count {
background-color: rgb(var(--bs-secondary-bg-rgb));
border: var(--bs-border-width) solid var(--bs-border-color);
border-bottom: 0px;
border-top-left-radius: 0.375rem;
border-top-right-radius: 0.375rem;
color: var(--bs-body-color);
display: flex;
flex-direction: row;
justify-content: space-around !important;
}
.ck-content {
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.ck-focused {
border-color: $input-focus-border-color !important;
box-shadow: $input-focus-box-shadow !important;
outline: 0 !important;
}
.ck-editor__main {
color: $body-color;
}
}
</style>