]> BookStack Code Mirror - bookstack/blob - resources/js/components/page-comment.ts
Comments: Added inline comment marker/highlight logic
[bookstack] / resources / js / components / page-comment.ts
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";
5
6 export class PageComment extends Component {
7
8     protected commentId: string;
9     protected commentLocalId: string;
10     protected commentContentRef: string;
11     protected deletedText: string;
12     protected updatedText: string;
13
14     protected wysiwygEditor: any = null;
15     protected wysiwygLanguage: string;
16     protected wysiwygTextDirection: string;
17
18     protected container: HTMLElement;
19     protected contentContainer: HTMLElement;
20     protected form: HTMLFormElement;
21     protected formCancel: HTMLElement;
22     protected editButton: HTMLElement;
23     protected deleteButton: HTMLElement;
24     protected replyButton: HTMLElement;
25     protected input: HTMLInputElement;
26
27     setup() {
28         // Options
29         this.commentId = this.$opts.commentId;
30         this.commentLocalId = this.$opts.commentLocalId;
31         this.commentContentRef = this.$opts.commentContentRef;
32         this.deletedText = this.$opts.deletedText;
33         this.updatedText = this.$opts.updatedText;
34
35         // Editor reference and text options
36         this.wysiwygLanguage = this.$opts.wysiwygLanguage;
37         this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
38
39         // Element references
40         this.container = this.$el;
41         this.contentContainer = this.$refs.contentContainer;
42         this.form = this.$refs.form as HTMLFormElement;
43         this.formCancel = this.$refs.formCancel;
44         this.editButton = this.$refs.editButton;
45         this.deleteButton = this.$refs.deleteButton;
46         this.replyButton = this.$refs.replyButton;
47         this.input = this.$refs.input as HTMLInputElement;
48
49         this.setupListeners();
50         this.positionForReference();
51     }
52
53     protected setupListeners(): void {
54         if (this.replyButton) {
55             this.replyButton.addEventListener('click', () => this.$emit('reply', {
56                 id: this.commentLocalId,
57                 element: this.container,
58             }));
59         }
60
61         if (this.editButton) {
62             this.editButton.addEventListener('click', this.startEdit.bind(this));
63             this.form.addEventListener('submit', this.update.bind(this));
64             this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
65         }
66
67         if (this.deleteButton) {
68             this.deleteButton.addEventListener('click', this.delete.bind(this));
69         }
70     }
71
72     protected toggleEditMode(show: boolean) : void {
73         this.contentContainer.toggleAttribute('hidden', show);
74         this.form.toggleAttribute('hidden', !show);
75     }
76
77     protected startEdit() : void {
78         this.toggleEditMode(true);
79
80         if (this.wysiwygEditor) {
81             this.wysiwygEditor.focus();
82             return;
83         }
84
85         const config = buildForInput({
86             language: this.wysiwygLanguage,
87             containerElement: this.input,
88             darkMode: document.documentElement.classList.contains('dark-mode'),
89             textDirection: this.wysiwygTextDirection,
90             drawioUrl: '',
91             pageId: 0,
92             translations: {},
93             translationMap: (window as Record<string, Object>).editor_translations,
94         });
95
96         (window as {tinymce: {init: (Object) => Promise<any>}}).tinymce.init(config).then(editors => {
97             this.wysiwygEditor = editors[0];
98             setTimeout(() => this.wysiwygEditor.focus(), 50);
99         });
100     }
101
102     protected async update(event: Event): Promise<void> {
103         event.preventDefault();
104         const loading = this.showLoading();
105         this.form.toggleAttribute('hidden', true);
106
107         const reqData = {
108             html: this.wysiwygEditor.getContent(),
109         };
110
111         try {
112             const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
113             const newComment = htmlToDom(resp.data as string);
114             this.container.replaceWith(newComment);
115             window.$events.success(this.updatedText);
116         } catch (err) {
117             console.error(err);
118             window.$events.showValidationErrors(err);
119             this.form.toggleAttribute('hidden', false);
120             loading.remove();
121         }
122     }
123
124     protected async delete(): Promise<void> {
125         this.showLoading();
126
127         await window.$http.delete(`/comment/${this.commentId}`);
128         this.$emit('delete');
129         this.container.closest('.comment-branch').remove();
130         window.$events.success(this.deletedText);
131     }
132
133     protected showLoading(): HTMLElement {
134         const loading = getLoading();
135         loading.classList.add('px-l');
136         this.container.append(loading);
137         return loading;
138     }
139
140     protected positionForReference() {
141         if (!this.commentContentRef) {
142             return;
143         }
144
145         const [refId, refHash, refRange] = this.commentContentRef.split(':');
146         const refEl = document.getElementById(refId);
147         if (!refEl) {
148             // TODO - Show outdated marker for comment
149             return;
150         }
151
152         const actualHash = hashElement(refEl);
153         if (actualHash !== refHash) {
154             // TODO - Show outdated marker for comment
155             return;
156         }
157
158         const refElBounds = refEl.getBoundingClientRect();
159         let bounds = refElBounds;
160         const [rangeStart, rangeEnd] = refRange.split('-');
161         if (rangeStart && rangeEnd) {
162             const range = new Range();
163             const relStart = findTargetNodeAndOffset(refEl, Number(rangeStart));
164             const relEnd = findTargetNodeAndOffset(refEl, Number(rangeEnd));
165             if (relStart && relEnd) {
166                 range.setStart(relStart.node, relStart.offset);
167                 range.setEnd(relEnd.node, relEnd.offset);
168                 bounds = range.getBoundingClientRect();
169             }
170         }
171
172         const relLeft = bounds.left - refElBounds.left;
173         const relTop = bounds.top - refElBounds.top;
174         // TODO - Extract to class, Use theme color
175         const marker = el('div', {
176             class: 'content-comment-highlight',
177             style: `left: ${relLeft}px; top: ${relTop}px; width: ${bounds.width}px; height: ${bounds.height}px;`
178         }, ['']);
179
180         refEl.style.position = 'relative';
181         refEl.append(marker);
182     }
183 }