1 import {Component} from './component';
2 import {findTargetNodeAndOffset, getLoading, hashElement, htmlToDom} from '../services/dom.ts';
3 import {buildForInput} from '../wysiwyg-tinymce/config';
4 import {el} from "../wysiwyg/utils/dom";
6 import commentIcon from "@icons/comment.svg"
8 export class PageComment extends Component {
10 protected commentId: string;
11 protected commentLocalId: string;
12 protected commentContentRef: string;
13 protected deletedText: string;
14 protected updatedText: string;
15 protected viewCommentText: string;
17 protected wysiwygEditor: any = null;
18 protected wysiwygLanguage: string;
19 protected wysiwygTextDirection: string;
21 protected container: HTMLElement;
22 protected contentContainer: HTMLElement;
23 protected form: HTMLFormElement;
24 protected formCancel: HTMLElement;
25 protected editButton: HTMLElement;
26 protected deleteButton: HTMLElement;
27 protected replyButton: HTMLElement;
28 protected input: HTMLInputElement;
32 this.commentId = this.$opts.commentId;
33 this.commentLocalId = this.$opts.commentLocalId;
34 this.commentContentRef = this.$opts.commentContentRef;
35 this.deletedText = this.$opts.deletedText;
36 this.updatedText = this.$opts.updatedText;
37 this.viewCommentText = this.$opts.viewCommentText;
39 // Editor reference and text options
40 this.wysiwygLanguage = this.$opts.wysiwygLanguage;
41 this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
44 this.container = this.$el;
45 this.contentContainer = this.$refs.contentContainer;
46 this.form = this.$refs.form as HTMLFormElement;
47 this.formCancel = this.$refs.formCancel;
48 this.editButton = this.$refs.editButton;
49 this.deleteButton = this.$refs.deleteButton;
50 this.replyButton = this.$refs.replyButton;
51 this.input = this.$refs.input as HTMLInputElement;
53 this.setupListeners();
54 this.positionForReference();
57 protected setupListeners(): void {
58 if (this.replyButton) {
59 this.replyButton.addEventListener('click', () => this.$emit('reply', {
60 id: this.commentLocalId,
61 element: this.container,
65 if (this.editButton) {
66 this.editButton.addEventListener('click', this.startEdit.bind(this));
67 this.form.addEventListener('submit', this.update.bind(this));
68 this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
71 if (this.deleteButton) {
72 this.deleteButton.addEventListener('click', this.delete.bind(this));
76 protected toggleEditMode(show: boolean) : void {
77 this.contentContainer.toggleAttribute('hidden', show);
78 this.form.toggleAttribute('hidden', !show);
81 protected startEdit() : void {
82 this.toggleEditMode(true);
84 if (this.wysiwygEditor) {
85 this.wysiwygEditor.focus();
89 const config = buildForInput({
90 language: this.wysiwygLanguage,
91 containerElement: this.input,
92 darkMode: document.documentElement.classList.contains('dark-mode'),
93 textDirection: this.wysiwygTextDirection,
97 translationMap: (window as Record<string, Object>).editor_translations,
100 (window as {tinymce: {init: (Object) => Promise<any>}}).tinymce.init(config).then(editors => {
101 this.wysiwygEditor = editors[0];
102 setTimeout(() => this.wysiwygEditor.focus(), 50);
106 protected async update(event: Event): Promise<void> {
107 event.preventDefault();
108 const loading = this.showLoading();
109 this.form.toggleAttribute('hidden', true);
112 html: this.wysiwygEditor.getContent(),
116 const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
117 const newComment = htmlToDom(resp.data as string);
118 this.container.replaceWith(newComment);
119 window.$events.success(this.updatedText);
122 window.$events.showValidationErrors(err);
123 this.form.toggleAttribute('hidden', false);
128 protected async delete(): Promise<void> {
131 await window.$http.delete(`/comment/${this.commentId}`);
132 this.$emit('delete');
133 this.container.closest('.comment-branch').remove();
134 window.$events.success(this.deletedText);
137 protected showLoading(): HTMLElement {
138 const loading = getLoading();
139 loading.classList.add('px-l');
140 this.container.append(loading);
144 protected positionForReference() {
145 if (!this.commentContentRef) {
149 const [refId, refHash, refRange] = this.commentContentRef.split(':');
150 const refEl = document.getElementById(refId);
152 // TODO - Show outdated marker for comment
156 const actualHash = hashElement(refEl);
157 if (actualHash !== refHash) {
158 // TODO - Show outdated marker for comment
162 const refElBounds = refEl.getBoundingClientRect();
163 let bounds = refElBounds;
164 const [rangeStart, rangeEnd] = refRange.split('-');
165 if (rangeStart && rangeEnd) {
166 const range = new Range();
167 const relStart = findTargetNodeAndOffset(refEl, Number(rangeStart));
168 const relEnd = findTargetNodeAndOffset(refEl, Number(rangeEnd));
169 if (relStart && relEnd) {
170 range.setStart(relStart.node, relStart.offset);
171 range.setEnd(relEnd.node, relEnd.offset);
172 bounds = range.getBoundingClientRect();
176 const relLeft = bounds.left - refElBounds.left;
177 const relTop = bounds.top - refElBounds.top;
179 const marker = el('button', {
181 class: 'content-comment-marker',
182 title: this.viewCommentText,
184 marker.innerHTML = <string>commentIcon;
185 marker.addEventListener('click', event => {
186 this.showCommentAtMarker(marker);
189 const markerWrap = el('div', {
190 class: 'content-comment-highlight',
191 style: `left: ${relLeft}px; top: ${relTop}px; width: ${bounds.width}px; height: ${bounds.height}px;`
194 refEl.style.position = 'relative';
195 refEl.append(markerWrap);
198 protected showCommentAtMarker(marker: HTMLElement): void {
200 marker.hidden = true;
201 const readClone = this.container.closest('.comment-branch').cloneNode(true) as HTMLElement;
202 const toRemove = readClone.querySelectorAll('.actions, form');
203 for (const el of toRemove) {
207 const close = el('button', {type: 'button'}, ['x']);
208 const jump = el('button', {type: 'button'}, ['Jump to thread']);
210 const commentWindow = el('div', {
211 class: 'content-comment-window'
214 class: 'content-comment-window-actions',
217 class: 'content-comment-window-content',
221 marker.parentElement.append(commentWindow);
223 const closeAction = () => {
224 commentWindow.remove();
225 marker.hidden = false;
228 close.addEventListener('click', closeAction.bind(this));
230 jump.addEventListener('click', () => {
232 this.container.scrollIntoView({behavior: 'smooth'});
233 const highlightTarget = this.container.querySelector('.header') as HTMLElement;
234 highlightTarget.classList.add('anim-highlight');
235 highlightTarget.addEventListener('animationend', () => highlightTarget.classList.remove('anim-highlight'))
238 // TODO - Position wrapper sensibly
239 // TODO - Movement control?