PageContainer.js 14 KB

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