2
0

PageContainer.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. import { pagePathUtils } from '@growi/core';
  2. import * as entities from 'entities';
  3. import * as toastr from 'toastr';
  4. import { Container } from 'unstated';
  5. import { EditorMode } from '~/stores/ui';
  6. import loggerFactory from '~/utils/logger';
  7. import { toastError } from '../util/apiNotification';
  8. import { apiPost } from '../util/apiv1-client';
  9. import { apiv3Post } from '../util/apiv3-client';
  10. import {
  11. DetachCodeBlockInterceptor,
  12. RestoreCodeBlockInterceptor,
  13. } from '../util/interceptor/detach-code-blocks';
  14. import {
  15. DrawioInterceptor,
  16. } from '../util/interceptor/drawio-interceptor';
  17. import { emojiMartData } from '../util/markdown-it/emoji-mart-data';
  18. const { isTrashPage } = pagePathUtils;
  19. const logger = loggerFactory('growi:services:PageContainer');
  20. /**
  21. * Service container related to Page
  22. * @extends {Container} unstated Container
  23. */
  24. export default class PageContainer extends Container {
  25. constructor(appContainer) {
  26. super();
  27. this.appContainer = appContainer;
  28. this.appContainer.registerContainer(this);
  29. this.state = {};
  30. const mainContent = document.querySelector('#content-main');
  31. if (mainContent == null) {
  32. logger.debug('#content-main element is not exists');
  33. return;
  34. }
  35. const revisionId = mainContent.getAttribute('data-page-revision-id');
  36. const path = decodeURI(mainContent.getAttribute('data-path'));
  37. this.state = {
  38. // local page data
  39. markdown: null, // will be initialized after initStateMarkdown()
  40. pageId: mainContent.getAttribute('data-page-id'),
  41. revisionId,
  42. revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
  43. path,
  44. tocHtml: '',
  45. createdAt: mainContent.getAttribute('data-page-created-at'),
  46. // please use useCurrentUpdatedAt instead
  47. updatedAt: mainContent.getAttribute('data-page-updated-at'),
  48. deletedAt: mainContent.getAttribute('data-page-deleted-at') || null,
  49. isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
  50. isTrashPage: isTrashPage(path),
  51. isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
  52. isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
  53. isPageExist: mainContent.getAttribute('data-page-id') != null,
  54. pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
  55. tags: null,
  56. hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
  57. templateTagData: mainContent.getAttribute('data-template-tags') || null,
  58. shareLinksNumber: mainContent.getAttribute('data-share-links-number'),
  59. shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
  60. // latest(on remote) information
  61. remoteRevisionId: revisionId,
  62. remoteRevisionBody: null,
  63. remoteRevisionUpdateAt: null,
  64. revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
  65. lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
  66. deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
  67. pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
  68. hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
  69. isHackmdDraftUpdatingInRealtime: false,
  70. isConflictDiffModalOpen: false,
  71. };
  72. // parse creator, lastUpdateUser and revisionAuthor
  73. try {
  74. this.state.creator = JSON.parse(mainContent.getAttribute('data-page-creator'));
  75. }
  76. catch (e) {
  77. logger.warn('The data of \'data-page-creator\' is invalid', e);
  78. }
  79. try {
  80. this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
  81. this.state.lastUpdateUser = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
  82. }
  83. catch (e) {
  84. logger.warn('The data of \'data-page-revision-author\' is invalid', e);
  85. }
  86. const { interceptorManager } = this.appContainer;
  87. interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(appContainer), 10); // process as soon as possible
  88. interceptorManager.addInterceptor(new DrawioInterceptor(appContainer), 20);
  89. interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
  90. this.initStateMarkdown();
  91. this.setTocHtml = this.setTocHtml.bind(this);
  92. this.save = this.save.bind(this);
  93. this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
  94. this.emitJoinPageRoomRequest();
  95. this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
  96. this.addWebSocketEventHandlers();
  97. const unlinkPageButton = document.getElementById('unlink-page-button');
  98. if (unlinkPageButton != null) {
  99. unlinkPageButton.addEventListener('click', async() => {
  100. try {
  101. const res = await apiPost('/pages.unlink', { path });
  102. window.location.href = encodeURI(`${res.path}?unlinked=true`);
  103. }
  104. catch (err) {
  105. toastError(err);
  106. }
  107. });
  108. }
  109. }
  110. /**
  111. * Workaround for the mangling in production build to break constructor.name
  112. */
  113. static getClassName() {
  114. return 'PageContainer';
  115. }
  116. /**
  117. * whether to Empty Trash Page
  118. * not displayed when guest user and not on trash page
  119. */
  120. get isAbleToShowEmptyTrashButton() {
  121. const { currentUser } = this.appContainer;
  122. const { path, hasChildren } = this.state;
  123. return (currentUser != null && currentUser.admin && path === '/trash' && hasChildren);
  124. }
  125. /**
  126. * whether to display trash management buttons
  127. * ex.) undo, delete completly
  128. * not displayed when guest user
  129. */
  130. get isAbleToShowTrashPageManagementButtons() {
  131. const { currentUser } = this.appContainer;
  132. const { isDeleted } = this.state;
  133. return (isDeleted && currentUser != null);
  134. }
  135. /**
  136. * initialize state for markdown data
  137. */
  138. initStateMarkdown() {
  139. let pageContent = '';
  140. const rawText = document.getElementById('raw-text-original');
  141. if (rawText) {
  142. pageContent = rawText.innerHTML;
  143. }
  144. const markdown = entities.decodeHTML(pageContent);
  145. this.state.markdown = markdown;
  146. }
  147. setLatestRemotePageData(s2cMessagePageUpdated) {
  148. const newState = {
  149. remoteRevisionId: s2cMessagePageUpdated.revisionId,
  150. remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
  151. remoteRevisionUpdateAt: s2cMessagePageUpdated.revisionUpdateAt,
  152. revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
  153. // TODO // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
  154. lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
  155. lastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
  156. };
  157. if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
  158. newState.hasDraftOnHackmd = s2cMessagePageUpdated.hasDraftOnHackmd;
  159. }
  160. this.setState(newState);
  161. }
  162. async setTocHtml(tocHtml) {
  163. if (this.state.tocHtml !== tocHtml) {
  164. const tocHtmlWithEmoji = await this.colonsToEmoji(tocHtml);
  165. this.setState({ tocHtml: tocHtmlWithEmoji });
  166. }
  167. }
  168. /**
  169. *
  170. * @param {*} html TOC html string
  171. * @returns TOC html with emoji (emoji-mart) in URL
  172. */
  173. async colonsToEmoji(html) {
  174. // Emoji colons matching
  175. const colons = ':[a-zA-Z0-9-_+]+:';
  176. // Emoji with skin tone matching
  177. const skin = ':skin-tone-[2-6]:';
  178. const colonsRegex = new RegExp(`(${colons}${skin}|${colons})`, 'g');
  179. const emojiData = await emojiMartData();
  180. return html.replace(colonsRegex, (index, match) => {
  181. const emojiName = match.slice(1, -1);
  182. return emojiData[emojiName];
  183. });
  184. }
  185. /**
  186. * save success handler
  187. * @param {object} page Page instance
  188. * @param {Array[Tag]} tags Array of Tag
  189. * @param {object} revision Revision instance
  190. */
  191. updateStateAfterSave(page, tags, revision, editorMode) {
  192. // update state of PageContainer
  193. const newState = {
  194. pageId: page._id,
  195. revisionId: revision._id,
  196. revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
  197. remoteRevisionId: revision._id,
  198. revisionAuthor: revision.author,
  199. revisionIdHackmdSynced: page.revisionHackmdSynced,
  200. hasDraftOnHackmd: page.hasDraftOnHackmd,
  201. markdown: revision.body,
  202. createdAt: page.createdAt,
  203. updatedAt: page.updatedAt,
  204. };
  205. if (tags != null) {
  206. newState.tags = tags;
  207. }
  208. this.setState(newState);
  209. // PageEditor component
  210. const pageEditor = this.appContainer.getComponentInstance('PageEditor');
  211. if (pageEditor != null) {
  212. if (editorMode !== EditorMode.Editor) {
  213. pageEditor.updateEditorValue(newState.markdown);
  214. }
  215. }
  216. // PageEditorByHackmd component
  217. const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
  218. if (pageEditorByHackmd != null) {
  219. // reset
  220. if (editorMode !== EditorMode.HackMD) {
  221. pageEditorByHackmd.reset();
  222. }
  223. }
  224. }
  225. /**
  226. * update page meta data
  227. * @param {object} page Page instance
  228. * @param {object} revision Revision instance
  229. * @param {String[]} tags Array of Tag
  230. */
  231. updatePageMetaData(page, revision, tags) {
  232. const newState = {
  233. revisionId: revision._id,
  234. revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
  235. remoteRevisionId: revision._id,
  236. revisionAuthor: revision.author,
  237. revisionIdHackmdSynced: page.revisionHackmdSynced,
  238. hasDraftOnHackmd: page.hasDraftOnHackmd,
  239. updatedAt: page.updatedAt,
  240. };
  241. if (tags != null) {
  242. newState.tags = tags;
  243. }
  244. this.setState(newState);
  245. }
  246. /**
  247. * Save page
  248. * @param {string} markdown
  249. * @param {object} optionsToSave
  250. * @return {object} { page: Page, tags: Tag[] }
  251. */
  252. async save(markdown, editorMode, optionsToSave = {}) {
  253. const { pageId, path } = this.state;
  254. let { revisionId } = this.state;
  255. const options = Object.assign({}, optionsToSave);
  256. if (editorMode === 'hackmd') {
  257. // set option to sync
  258. options.isSyncRevisionToHackmd = true;
  259. revisionId = this.state.revisionIdHackmdSynced;
  260. }
  261. let res;
  262. if (pageId == null) {
  263. res = await this.createPage(path, markdown, options);
  264. }
  265. else {
  266. res = await this.updatePage(pageId, revisionId, markdown, options);
  267. }
  268. this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
  269. return res;
  270. }
  271. async saveAndReload(optionsToSave, editorMode) {
  272. if (optionsToSave == null) {
  273. const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
  274. throw new Error(msg);
  275. }
  276. if (editorMode == null) {
  277. logger.warn('\'saveAndReload\' requires the \'editorMode\' param');
  278. return;
  279. }
  280. const { pageId, path } = this.state;
  281. let { revisionId } = this.state;
  282. const options = Object.assign({}, optionsToSave);
  283. let markdown;
  284. if (editorMode === 'hackmd') {
  285. const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
  286. markdown = await pageEditorByHackmd.getMarkdown();
  287. // set option to sync
  288. options.isSyncRevisionToHackmd = true;
  289. revisionId = this.state.revisionIdHackmdSynced;
  290. }
  291. else {
  292. const pageEditor = this.appContainer.getComponentInstance('PageEditor');
  293. markdown = pageEditor.getMarkdown();
  294. }
  295. let res;
  296. if (pageId == null) {
  297. res = await this.createPage(path, markdown, options);
  298. }
  299. else {
  300. res = await this.updatePage(pageId, revisionId, markdown, options);
  301. }
  302. const editorContainer = this.appContainer.getContainer('EditorContainer');
  303. editorContainer.clearDraft(path);
  304. window.location.href = path;
  305. return res;
  306. }
  307. async createPage(pagePath, markdown, tmpParams) {
  308. const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
  309. // clone
  310. const params = Object.assign(tmpParams, {
  311. path: pagePath,
  312. body: markdown,
  313. });
  314. const res = await apiv3Post('/pages/', params);
  315. const { page, tags, revision } = res.data;
  316. return { page, tags, revision };
  317. }
  318. async updatePage(pageId, revisionId, markdown, tmpParams) {
  319. const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
  320. // clone
  321. const params = Object.assign(tmpParams, {
  322. page_id: pageId,
  323. revision_id: revisionId,
  324. body: markdown,
  325. });
  326. const res = await apiPost('/pages.update', params);
  327. if (!res.ok) {
  328. throw new Error(res.error);
  329. }
  330. return res;
  331. }
  332. showSuccessToastr() {
  333. toastr.success(undefined, 'Saved successfully', {
  334. closeButton: true,
  335. progressBar: true,
  336. newestOnTop: false,
  337. showDuration: '100',
  338. hideDuration: '100',
  339. timeOut: '1200',
  340. extendedTimeOut: '150',
  341. });
  342. }
  343. showErrorToastr(error) {
  344. toastr.error(error.message, 'Error occured', {
  345. closeButton: true,
  346. progressBar: true,
  347. newestOnTop: false,
  348. showDuration: '100',
  349. hideDuration: '100',
  350. timeOut: '3000',
  351. });
  352. }
  353. // request to server so the client to join a room for each page
  354. emitJoinPageRoomRequest() {
  355. const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
  356. const socket = socketIoContainer.getSocket();
  357. socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
  358. }
  359. addWebSocketEventHandlers() {
  360. // eslint-disable-next-line @typescript-eslint/no-this-alias
  361. const pageContainer = this;
  362. const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
  363. const socket = socketIoContainer.getSocket();
  364. socket.on('page:create', (data) => {
  365. logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
  366. // update remote page data
  367. const { s2cMessagePageUpdated } = data;
  368. if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
  369. pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
  370. }
  371. });
  372. socket.on('page:update', (data) => {
  373. logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
  374. // update remote page data
  375. const { s2cMessagePageUpdated } = data;
  376. if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
  377. pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
  378. }
  379. });
  380. socket.on('page:delete', (data) => {
  381. logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
  382. // update remote page data
  383. const { s2cMessagePageUpdated } = data;
  384. if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
  385. pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
  386. }
  387. });
  388. socket.on('page:editingWithHackmd', (data) => {
  389. logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
  390. // update isHackmdDraftUpdatingInRealtime
  391. const { s2cMessagePageUpdated } = data;
  392. if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
  393. pageContainer.setState({ isHackmdDraftUpdatingInRealtime: true });
  394. }
  395. });
  396. }
  397. /* TODO GW-325 */
  398. retrieveMyBookmarkList() {
  399. }
  400. async resolveConflict(markdown, editorMode) {
  401. const { pageId, remoteRevisionId, path } = this.state;
  402. const editorContainer = this.appContainer.getContainer('EditorContainer');
  403. const pageEditor = this.appContainer.getComponentInstance('PageEditor');
  404. const options = editorContainer.getCurrentOptionsToSave();
  405. const optionsToSave = Object.assign({}, options);
  406. const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
  407. editorContainer.clearDraft(path);
  408. this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
  409. if (pageEditor != null) {
  410. pageEditor.updateEditorValue(markdown);
  411. }
  412. editorContainer.setState({ tags: res.tags });
  413. return res;
  414. }
  415. }