PageContainer.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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 {
  8. DetachCodeBlockInterceptor,
  9. RestoreCodeBlockInterceptor,
  10. } from '../../services/renderer/interceptor/detach-code-blocks';
  11. import {
  12. DrawioInterceptor,
  13. } from '../../services/renderer/interceptor/drawio-interceptor';
  14. import { toastError } from '../util/apiNotification';
  15. import { apiPost } from '../util/apiv1-client';
  16. const { isTrashPage } = pagePathUtils;
  17. const logger = loggerFactory('growi:services:PageContainer');
  18. /**
  19. * Service container related to Page
  20. * @extends {Container} unstated Container
  21. */
  22. export default class PageContainer extends Container {
  23. constructor(appContainer) {
  24. super();
  25. this.appContainer = appContainer;
  26. this.appContainer.registerContainer(this);
  27. this.state = {};
  28. const mainContent = document.querySelector('#content-main');
  29. if (mainContent == null) {
  30. logger.debug('#content-main element is not exists');
  31. return;
  32. }
  33. const revisionId = mainContent.getAttribute('data-page-revision-id');
  34. const path = decodeURI(mainContent.getAttribute('data-path'));
  35. this.state = {
  36. // local page data
  37. markdown: null, // will be initialized after initStateMarkdown()
  38. pageId: mainContent.getAttribute('data-page-id'),
  39. revisionId,
  40. revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
  41. path,
  42. isEmpty: mainContent.getAttribute('data-page-is-empty'),
  43. deletedAt: mainContent.getAttribute('data-page-deleted-at') || null,
  44. isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
  45. isTrashPage: isTrashPage(path),
  46. isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
  47. isPageExist: mainContent.getAttribute('data-page-id') != null,
  48. pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
  49. tags: null,
  50. hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
  51. templateTagData: mainContent.getAttribute('data-template-tags') || null,
  52. shareLinksNumber: mainContent.getAttribute('data-share-links-number'),
  53. shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
  54. // latest(on remote) information
  55. remoteRevisionId: revisionId,
  56. remoteRevisionBody: null,
  57. remoteRevisionUpdateAt: null,
  58. revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
  59. lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
  60. deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
  61. pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
  62. hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
  63. isHackmdDraftUpdatingInRealtime: false,
  64. isConflictDiffModalOpen: false,
  65. };
  66. // parse creator, lastUpdateUser and revisionAuthor
  67. try {
  68. this.state.creator = JSON.parse(mainContent.getAttribute('data-page-creator'));
  69. }
  70. catch (e) {
  71. logger.warn('The data of \'data-page-creator\' is invalid', e);
  72. }
  73. try {
  74. this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
  75. this.state.lastUpdateUser = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
  76. }
  77. catch (e) {
  78. logger.warn('The data of \'data-page-revision-author\' is invalid', e);
  79. }
  80. const { interceptorManager } = window;
  81. interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(), 10); // process as soon as possible
  82. interceptorManager.addInterceptor(new DrawioInterceptor(), 20);
  83. interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(), 900); // process as late as possible
  84. this.initStateMarkdown();
  85. this.save = this.save.bind(this);
  86. this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
  87. this.emitJoinPageRoomRequest();
  88. this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
  89. this.addWebSocketEventHandlers();
  90. const unlinkPageButton = document.getElementById('unlink-page-button');
  91. if (unlinkPageButton != null) {
  92. unlinkPageButton.addEventListener('click', async() => {
  93. try {
  94. const res = await apiPost('/pages.unlink', { path });
  95. window.location.href = encodeURI(`${res.path}?unlinked=true`);
  96. }
  97. catch (err) {
  98. toastError(err);
  99. }
  100. });
  101. }
  102. }
  103. /**
  104. * Workaround for the mangling in production build to break constructor.name
  105. */
  106. static getClassName() {
  107. return 'PageContainer';
  108. }
  109. /**
  110. * initialize state for markdown data
  111. * [Already SWRized]
  112. */
  113. initStateMarkdown() {
  114. let pageContent = '';
  115. const rawText = document.getElementById('raw-text-original');
  116. if (rawText) {
  117. pageContent = rawText.innerHTML;
  118. }
  119. const markdown = entities.decodeHTML(pageContent);
  120. this.state.markdown = markdown;
  121. }
  122. setLatestRemotePageData(s2cMessagePageUpdated) {
  123. const newState = {
  124. remoteRevisionId: s2cMessagePageUpdated.revisionId,
  125. remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
  126. remoteRevisionUpdateAt: s2cMessagePageUpdated.revisionUpdateAt,
  127. revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
  128. // TODO // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
  129. lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
  130. lastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
  131. };
  132. if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
  133. newState.hasDraftOnHackmd = s2cMessagePageUpdated.hasDraftOnHackmd;
  134. }
  135. this.setState(newState);
  136. }
  137. /**
  138. * save success handler
  139. * @param {object} page Page instance
  140. * @param {Array[Tag]} tags Array of Tag
  141. * @param {object} revision Revision instance
  142. */
  143. updateStateAfterSave(page, tags, revision, editorMode) {
  144. // update state of PageContainer
  145. // const newState = {
  146. // pageId: page._id,
  147. // revisionId: revision._id,
  148. // revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
  149. // remoteRevisionId: revision._id,
  150. // revisionAuthor: revision.author,
  151. // revisionIdHackmdSynced: page.revisionHackmdSynced,
  152. // hasDraftOnHackmd: page.hasDraftOnHackmd,
  153. // markdown: revision.body,
  154. // createdAt: page.createdAt,
  155. // updatedAt: page.updatedAt,
  156. // };
  157. // if (tags != null) {
  158. // newState.tags = tags;
  159. // }
  160. // this.setState(newState);
  161. // PageEditorByHackmd component
  162. // const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
  163. // if (pageEditorByHackmd != null) {
  164. // // reset
  165. // if (editorMode !== EditorMode.HackMD) {
  166. // pageEditorByHackmd.reset();
  167. // }
  168. // }
  169. }
  170. /**
  171. * update page meta data
  172. * @param {object} page Page instance
  173. * @param {object} revision Revision instance
  174. * @param {String[]} tags Array of Tag
  175. */
  176. updatePageMetaData(page, revision, tags) {
  177. const newState = {
  178. revisionId: revision._id,
  179. revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
  180. remoteRevisionId: revision._id,
  181. revisionAuthor: revision.author,
  182. revisionIdHackmdSynced: page.revisionHackmdSynced,
  183. hasDraftOnHackmd: page.hasDraftOnHackmd,
  184. updatedAt: page.updatedAt,
  185. };
  186. if (tags != null) {
  187. newState.tags = tags;
  188. }
  189. this.setState(newState);
  190. }
  191. /**
  192. * Save page
  193. * @param {string} markdown
  194. * @param {object} optionsToSave
  195. * @return {object} { page: Page, tags: Tag[] }
  196. */
  197. async save(markdown, editorMode, optionsToSave = {}) {
  198. const { pageId, path } = this.state;
  199. let { revisionId } = this.state;
  200. const options = Object.assign({}, optionsToSave);
  201. if (editorMode === EditorMode.HackMD) {
  202. // set option to sync
  203. options.isSyncRevisionToHackmd = true;
  204. revisionId = this.state.revisionIdHackmdSynced;
  205. }
  206. let res;
  207. if (pageId == null) {
  208. res = await this.createPage(path, markdown, options);
  209. }
  210. else {
  211. res = await this.updatePage(pageId, revisionId, markdown, options);
  212. }
  213. this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
  214. return res;
  215. }
  216. showSuccessToastr() {
  217. toastr.success(undefined, 'Saved successfully', {
  218. closeButton: true,
  219. progressBar: true,
  220. newestOnTop: false,
  221. showDuration: '100',
  222. hideDuration: '100',
  223. timeOut: '1200',
  224. extendedTimeOut: '150',
  225. });
  226. }
  227. showErrorToastr(error) {
  228. toastr.error(error.message, 'Error occured', {
  229. closeButton: true,
  230. progressBar: true,
  231. newestOnTop: false,
  232. showDuration: '100',
  233. hideDuration: '100',
  234. timeOut: '3000',
  235. });
  236. }
  237. // request to server so the client to join a room for each page
  238. emitJoinPageRoomRequest() {
  239. const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
  240. const socket = socketIoContainer.getSocket();
  241. socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
  242. }
  243. addWebSocketEventHandlers() {
  244. // eslint-disable-next-line @typescript-eslint/no-this-alias
  245. const pageContainer = this;
  246. const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
  247. const socket = socketIoContainer.getSocket();
  248. socket.on('page:create', (data) => {
  249. logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
  250. // update remote page data
  251. const { s2cMessagePageUpdated } = data;
  252. if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
  253. pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
  254. }
  255. });
  256. socket.on('page:update', (data) => {
  257. logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
  258. // update remote page data
  259. const { s2cMessagePageUpdated } = data;
  260. if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
  261. pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
  262. }
  263. });
  264. socket.on('page:delete', (data) => {
  265. logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
  266. // update remote page data
  267. const { s2cMessagePageUpdated } = data;
  268. if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
  269. pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
  270. }
  271. });
  272. socket.on('page:editingWithHackmd', (data) => {
  273. logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
  274. // update isHackmdDraftUpdatingInRealtime
  275. const { s2cMessagePageUpdated } = data;
  276. if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
  277. pageContainer.setState({ isHackmdDraftUpdatingInRealtime: true });
  278. }
  279. });
  280. }
  281. /* TODO GW-325 */
  282. retrieveMyBookmarkList() {
  283. }
  284. async resolveConflict(markdown, editorMode) {
  285. const { pageId, remoteRevisionId, path } = this.state;
  286. const editorContainer = this.appContainer.getContainer('EditorContainer');
  287. const options = editorContainer.getCurrentOptionsToSave();
  288. const optionsToSave = Object.assign({}, options);
  289. const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
  290. editorContainer.clearDraft(path);
  291. this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
  292. // Update PageEditor component
  293. if (editorMode !== EditorMode.Editor) {
  294. // eslint-disable-next-line no-undef
  295. globalEmitter.emit('updateEditorValue', markdown);
  296. }
  297. editorContainer.setState({ tags: res.tags });
  298. return res;
  299. }
  300. }