PageContainer.js 16 KB

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