]>
Commit | Line | Data |
---|---|---|
1 | import { first } from 'rxjs/operators' | |
2 | import { ComponentRef, Injectable } from '@angular/core' | |
3 | import { MarkdownService } from '@app/core' | |
4 | import { | |
5 | ButtonMarkupData, | |
6 | ChannelMiniatureMarkupData, | |
7 | ContainerMarkupData, | |
8 | EmbedMarkupData, | |
9 | PlaylistMiniatureMarkupData, | |
10 | VideoFilter, | |
11 | VideoMiniatureMarkupData, | |
12 | VideosListMarkupData | |
13 | } from '@shared/models' | |
14 | import { DynamicElementService } from './dynamic-element.service' | |
15 | import { | |
16 | ButtonMarkupComponent, | |
17 | ChannelMiniatureMarkupComponent, | |
18 | EmbedMarkupComponent, | |
19 | PlaylistMiniatureMarkupComponent, | |
20 | VideoMiniatureMarkupComponent, | |
21 | VideosListMarkupComponent | |
22 | } from './peertube-custom-tags' | |
23 | import { CustomMarkupComponent } from './peertube-custom-tags/shared' | |
24 | ||
25 | type AngularBuilderFunction = (el: HTMLElement) => ComponentRef<CustomMarkupComponent> | |
26 | type HTMLBuilderFunction = (el: HTMLElement) => HTMLElement | |
27 | ||
28 | @Injectable() | |
29 | export class CustomMarkupService { | |
30 | private angularBuilders: { [ selector: string ]: AngularBuilderFunction } = { | |
31 | 'peertube-button': el => this.buttonBuilder(el), | |
32 | 'peertube-video-embed': el => this.embedBuilder(el, 'video'), | |
33 | 'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'), | |
34 | 'peertube-video-miniature': el => this.videoMiniatureBuilder(el), | |
35 | 'peertube-playlist-miniature': el => this.playlistMiniatureBuilder(el), | |
36 | 'peertube-channel-miniature': el => this.channelMiniatureBuilder(el), | |
37 | 'peertube-videos-list': el => this.videosListBuilder(el) | |
38 | } | |
39 | ||
40 | private htmlBuilders: { [ selector: string ]: HTMLBuilderFunction } = { | |
41 | 'peertube-container': el => this.containerBuilder(el) | |
42 | } | |
43 | ||
44 | private customMarkdownRenderer: (text: string) => Promise<HTMLElement> | |
45 | ||
46 | constructor ( | |
47 | private dynamicElementService: DynamicElementService, | |
48 | private markdown: MarkdownService | |
49 | ) { | |
50 | this.customMarkdownRenderer = (text: string) => { | |
51 | return this.buildElement(text) | |
52 | .then(({ rootElement }) => rootElement) | |
53 | } | |
54 | } | |
55 | ||
56 | getCustomMarkdownRenderer () { | |
57 | return this.customMarkdownRenderer | |
58 | } | |
59 | ||
60 | async buildElement (text: string) { | |
61 | const html = await this.markdown.customPageMarkdownToHTML(text, this.getSupportedTags()) | |
62 | ||
63 | const rootElement = document.createElement('div') | |
64 | rootElement.innerHTML = html | |
65 | ||
66 | for (const selector of Object.keys(this.htmlBuilders)) { | |
67 | rootElement.querySelectorAll(selector) | |
68 | .forEach((e: HTMLElement) => { | |
69 | try { | |
70 | const element = this.execHTMLBuilder(selector, e) | |
71 | // Insert as first child | |
72 | e.insertBefore(element, e.firstChild) | |
73 | } catch (err) { | |
74 | console.error('Cannot inject component %s.', selector, err) | |
75 | } | |
76 | }) | |
77 | } | |
78 | ||
79 | const loadedPromises: Promise<boolean>[] = [] | |
80 | ||
81 | for (const selector of Object.keys(this.angularBuilders)) { | |
82 | rootElement.querySelectorAll(selector) | |
83 | .forEach((e: HTMLElement) => { | |
84 | try { | |
85 | const component = this.execAngularBuilder(selector, e) | |
86 | ||
87 | if (component.instance.loaded) { | |
88 | const p = component.instance.loaded.pipe(first()).toPromise() | |
89 | loadedPromises.push(p) | |
90 | } | |
91 | ||
92 | this.dynamicElementService.injectElement(e, component) | |
93 | } catch (err) { | |
94 | console.error('Cannot inject component %s.', selector, err) | |
95 | } | |
96 | }) | |
97 | } | |
98 | ||
99 | return { rootElement, componentsLoaded: Promise.all(loadedPromises) } | |
100 | } | |
101 | ||
102 | private getSupportedTags () { | |
103 | return Object.keys(this.angularBuilders) | |
104 | .concat(Object.keys(this.htmlBuilders)) | |
105 | } | |
106 | ||
107 | private execHTMLBuilder (selector: string, el: HTMLElement) { | |
108 | return this.htmlBuilders[selector](el) | |
109 | } | |
110 | ||
111 | private execAngularBuilder (selector: string, el: HTMLElement) { | |
112 | return this.angularBuilders[selector](el) | |
113 | } | |
114 | ||
115 | private embedBuilder (el: HTMLElement, type: 'video' | 'playlist') { | |
116 | const data = el.dataset as EmbedMarkupData | |
117 | const component = this.dynamicElementService.createElement(EmbedMarkupComponent) | |
118 | ||
119 | this.dynamicElementService.setModel(component, { uuid: data.uuid, type }) | |
120 | ||
121 | return component | |
122 | } | |
123 | ||
124 | private playlistMiniatureBuilder (el: HTMLElement) { | |
125 | const data = el.dataset as PlaylistMiniatureMarkupData | |
126 | const component = this.dynamicElementService.createElement(PlaylistMiniatureMarkupComponent) | |
127 | ||
128 | this.dynamicElementService.setModel(component, { uuid: data.uuid }) | |
129 | ||
130 | return component | |
131 | } | |
132 | ||
133 | private channelMiniatureBuilder (el: HTMLElement) { | |
134 | const data = el.dataset as ChannelMiniatureMarkupData | |
135 | const component = this.dynamicElementService.createElement(ChannelMiniatureMarkupComponent) | |
136 | ||
137 | const model = { | |
138 | name: data.name, | |
139 | displayLatestVideo: this.buildBoolean(data.displayLatestVideo) ?? true, | |
140 | displayDescription: this.buildBoolean(data.displayDescription) ?? true | |
141 | } | |
142 | ||
143 | this.dynamicElementService.setModel(component, model) | |
144 | ||
145 | return component | |
146 | } | |
147 | ||
148 | private buttonBuilder (el: HTMLElement) { | |
149 | const data = el.dataset as ButtonMarkupData | |
150 | const component = this.dynamicElementService.createElement(ButtonMarkupComponent) | |
151 | ||
152 | const model = { | |
153 | theme: data.theme ?? 'primary', | |
154 | href: data.href, | |
155 | label: data.label, | |
156 | blankTarget: this.buildBoolean(data.blankTarget) ?? false | |
157 | } | |
158 | this.dynamicElementService.setModel(component, model) | |
159 | ||
160 | return component | |
161 | } | |
162 | ||
163 | private videoMiniatureBuilder (el: HTMLElement) { | |
164 | const data = el.dataset as VideoMiniatureMarkupData | |
165 | const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent) | |
166 | ||
167 | const model = { | |
168 | uuid: data.uuid, | |
169 | onlyDisplayTitle: this.buildBoolean(data.onlyDisplayTitle) ?? false | |
170 | } | |
171 | ||
172 | this.dynamicElementService.setModel(component, model) | |
173 | ||
174 | return component | |
175 | } | |
176 | ||
177 | private videosListBuilder (el: HTMLElement) { | |
178 | const data = el.dataset as VideosListMarkupData | |
179 | const component = this.dynamicElementService.createElement(VideosListMarkupComponent) | |
180 | ||
181 | const model = { | |
182 | onlyDisplayTitle: this.buildBoolean(data.onlyDisplayTitle) ?? false, | |
183 | maxRows: this.buildNumber(data.maxRows) ?? -1, | |
184 | ||
185 | sort: data.sort || '-publishedAt', | |
186 | count: this.buildNumber(data.count) || 10, | |
187 | ||
188 | categoryOneOf: this.buildArrayNumber(data.categoryOneOf) ?? [], | |
189 | languageOneOf: this.buildArrayString(data.languageOneOf) ?? [], | |
190 | ||
191 | accountHandle: data.accountHandle || undefined, | |
192 | channelHandle: data.channelHandle || undefined, | |
193 | ||
194 | filter: this.buildBoolean(data.onlyLocal) ? 'local' as VideoFilter : undefined | |
195 | } | |
196 | ||
197 | this.dynamicElementService.setModel(component, model) | |
198 | ||
199 | return component | |
200 | } | |
201 | ||
202 | private containerBuilder (el: HTMLElement) { | |
203 | const data = el.dataset as ContainerMarkupData | |
204 | ||
205 | // Move inner HTML in the new element we'll create | |
206 | const content = el.innerHTML | |
207 | el.innerHTML = '' | |
208 | ||
209 | const root = document.createElement('div') | |
210 | root.innerHTML = content | |
211 | ||
212 | const layoutClass = data.layout | |
213 | ? 'layout-' + data.layout | |
214 | : 'layout-column' | |
215 | ||
216 | root.classList.add('peertube-container', layoutClass) | |
217 | ||
218 | if (data.width) { | |
219 | root.setAttribute('width', data.width) | |
220 | } | |
221 | ||
222 | if (data.title || data.description) { | |
223 | const headerElement = document.createElement('div') | |
224 | headerElement.classList.add('header') | |
225 | ||
226 | if (data.title) { | |
227 | const titleElement = document.createElement('h4') | |
228 | titleElement.innerText = data.title | |
229 | headerElement.appendChild(titleElement) | |
230 | } | |
231 | ||
232 | if (data.description) { | |
233 | const descriptionElement = document.createElement('div') | |
234 | descriptionElement.innerText = data.description | |
235 | headerElement.append(descriptionElement) | |
236 | } | |
237 | ||
238 | root.insertBefore(headerElement, root.firstChild) | |
239 | } | |
240 | ||
241 | return root | |
242 | } | |
243 | ||
244 | private buildNumber (value: string) { | |
245 | if (!value) return undefined | |
246 | ||
247 | return parseInt(value, 10) | |
248 | } | |
249 | ||
250 | private buildBoolean (value: string) { | |
251 | if (value === 'true') return true | |
252 | if (value === 'false') return false | |
253 | ||
254 | return undefined | |
255 | } | |
256 | ||
257 | private buildArrayNumber (value: string) { | |
258 | if (!value) return undefined | |
259 | ||
260 | return value.split(',').map(v => parseInt(v, 10)) | |
261 | } | |
262 | ||
263 | private buildArrayString (value: string) { | |
264 | if (!value) return undefined | |
265 | ||
266 | return value.split(',') | |
267 | } | |
268 | } |