PageContainer.js 11 KB

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