PageContainer.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import { Container } from 'unstated';
  2. import loggerFactory from '@alias/logger';
  3. import * as entities from 'entities';
  4. import * as toastr from 'toastr';
  5. const logger = loggerFactory('growi:services:PageContainer');
  6. const scrollThresForSticky = 0;
  7. const scrollThresForCompact = 30;
  8. const scrollThresForThrottling = 100;
  9. /**
  10. * Service container related to Page
  11. * @extends {Container} unstated Container
  12. */
  13. export default class PageContainer extends Container {
  14. constructor(appContainer) {
  15. super();
  16. this.appContainer = appContainer;
  17. this.appContainer.registerContainer(this);
  18. this.state = {};
  19. const mainContent = document.querySelector('#content-main');
  20. if (mainContent == null) {
  21. logger.debug('#content-main element is not exists');
  22. return;
  23. }
  24. const revisionId = mainContent.getAttribute('data-page-revision-id');
  25. const path = decodeURI(mainContent.getAttribute('data-path'));
  26. this.state = {
  27. // local page data
  28. markdown: null, // will be initialized after initStateMarkdown()
  29. pageId: mainContent.getAttribute('data-page-id'),
  30. revisionId,
  31. revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
  32. revisionAuthor: JSON.parse(mainContent.getAttribute('data-page-revision-author')),
  33. path,
  34. tocHtml: '',
  35. isLiked: JSON.parse(mainContent.getAttribute('data-page-is-liked')),
  36. seenUserIds: [],
  37. likerUserIds: [],
  38. createdAt: mainContent.getAttribute('data-page-created-at'),
  39. creator: JSON.parse(mainContent.getAttribute('data-page-creator')),
  40. updatedAt: mainContent.getAttribute('data-page-updated-at'),
  41. isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
  42. isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
  43. tags: [],
  44. hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
  45. templateTagData: mainContent.getAttribute('data-template-tags') || null,
  46. // latest(on remote) information
  47. remoteRevisionId: revisionId,
  48. revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
  49. lastUpdateUsername: undefined,
  50. pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
  51. hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
  52. isHackmdDraftUpdatingInRealtime: false,
  53. isHeaderSticky: false,
  54. isSubnavCompact: false,
  55. };
  56. this.initStateMarkdown();
  57. this.initStateOthers();
  58. this.setTocHtml = this.setTocHtml.bind(this);
  59. this.save = this.save.bind(this);
  60. this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
  61. this.addWebSocketEventHandlers();
  62. window.addEventListener('scroll', () => {
  63. const currentYOffset = window.pageYOffset;
  64. // original throttling
  65. if (this.state.isSubnavCompact && scrollThresForThrottling < currentYOffset) {
  66. return;
  67. }
  68. this.setState({
  69. isHeaderSticky: scrollThresForSticky < currentYOffset,
  70. isSubnavCompact: scrollThresForCompact < currentYOffset,
  71. });
  72. });
  73. }
  74. /**
  75. * Workaround for the mangling in production build to break constructor.name
  76. */
  77. static getClassName() {
  78. return 'PageContainer';
  79. }
  80. /**
  81. * initialize state for markdown data
  82. */
  83. initStateMarkdown() {
  84. let pageContent = '';
  85. const rawText = document.getElementById('raw-text-original');
  86. if (rawText) {
  87. pageContent = rawText.innerHTML;
  88. }
  89. const markdown = entities.decodeHTML(pageContent);
  90. this.state.markdown = markdown;
  91. }
  92. initStateOthers() {
  93. const seenUserListElem = document.getElementById('seen-user-list');
  94. if (seenUserListElem != null) {
  95. const userIdsStr = seenUserListElem.dataset.userIds;
  96. this.state.seenUserIds = userIdsStr.split(',');
  97. }
  98. const likerListElem = document.getElementById('liker-list');
  99. if (likerListElem != null) {
  100. const userIdsStr = likerListElem.dataset.userIds;
  101. this.state.likerUserIds = userIdsStr.split(',');
  102. }
  103. }
  104. setLatestRemotePageData(page, user) {
  105. this.setState({
  106. remoteRevisionId: page.revision._id,
  107. revisionIdHackmdSynced: page.revisionHackmdSynced,
  108. lastUpdateUsername: user.name,
  109. });
  110. }
  111. setTocHtml(tocHtml) {
  112. if (this.state.tocHtml !== tocHtml) {
  113. this.setState({ tocHtml });
  114. }
  115. }
  116. /**
  117. * save success handler
  118. * @param {object} page Page instance
  119. * @param {Array[Tag]} tags Array of Tag
  120. */
  121. updateStateAfterSave(page, tags) {
  122. const { editorMode } = this.appContainer.state;
  123. // update state of PageContainer
  124. const newState = {
  125. pageId: page._id,
  126. revisionId: page.revision._id,
  127. revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
  128. remoteRevisionId: page.revision._id,
  129. revisionIdHackmdSynced: page.revisionHackmdSynced,
  130. hasDraftOnHackmd: page.hasDraftOnHackmd,
  131. markdown: page.revision.body,
  132. };
  133. if (tags != null) {
  134. newState.tags = tags;
  135. }
  136. this.setState(newState);
  137. // PageEditor component
  138. const pageEditor = this.appContainer.getComponentInstance('PageEditor');
  139. if (pageEditor != null) {
  140. if (editorMode !== 'builtin') {
  141. pageEditor.updateEditorValue(newState.markdown);
  142. }
  143. }
  144. // PageEditorByHackmd component
  145. const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
  146. if (pageEditorByHackmd != null) {
  147. // reset
  148. if (editorMode !== 'hackmd') {
  149. pageEditorByHackmd.reset();
  150. }
  151. }
  152. // hidden input
  153. $('input[name="revision_id"]').val(newState.revisionId);
  154. }
  155. /**
  156. * Save page
  157. * @param {string} markdown
  158. * @param {object} optionsToSave
  159. * @return {object} { page: Page, tags: Tag[] }
  160. */
  161. async save(markdown, optionsToSave = {}) {
  162. const { editorMode } = this.appContainer.state;
  163. const { pageId, path } = this.state;
  164. let { revisionId } = this.state;
  165. const options = Object.assign({}, optionsToSave);
  166. if (editorMode === 'hackmd') {
  167. // set option to sync
  168. options.isSyncRevisionToHackmd = true;
  169. revisionId = this.state.revisionIdHackmdSynced;
  170. }
  171. let res;
  172. if (pageId == null) {
  173. res = await this.createPage(path, markdown, options);
  174. }
  175. else {
  176. res = await this.updatePage(pageId, revisionId, markdown, options);
  177. }
  178. this.updateStateAfterSave(res.page, res.tags);
  179. return res;
  180. }
  181. async saveAndReload(optionsToSave) {
  182. if (optionsToSave == null) {
  183. const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
  184. throw new Error(msg);
  185. }
  186. const { editorMode } = this.appContainer.state;
  187. if (editorMode == null) {
  188. logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
  189. return;
  190. }
  191. const { pageId, path } = this.state;
  192. let { revisionId } = this.state;
  193. const options = Object.assign({}, optionsToSave);
  194. let markdown;
  195. if (editorMode === 'hackmd') {
  196. const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
  197. markdown = await pageEditorByHackmd.getMarkdown();
  198. // set option to sync
  199. options.isSyncRevisionToHackmd = true;
  200. revisionId = this.state.revisionIdHackmdSynced;
  201. }
  202. else {
  203. const pageEditor = this.appContainer.getComponentInstance('PageEditor');
  204. markdown = pageEditor.getMarkdown();
  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. const editorContainer = this.appContainer.getContainer('EditorContainer');
  214. editorContainer.clearDraft(path);
  215. window.location.href = path;
  216. return res;
  217. }
  218. async createPage(pagePath, markdown, tmpParams) {
  219. const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
  220. // clone
  221. const params = Object.assign(tmpParams, {
  222. socketClientId: websocketContainer.getSocketClientId(),
  223. path: pagePath,
  224. body: markdown,
  225. });
  226. const res = await this.appContainer.apiPost('/pages.create', params);
  227. if (!res.ok) {
  228. throw new Error(res.error);
  229. }
  230. return { page: res.page, tags: res.tags };
  231. }
  232. async updatePage(pageId, revisionId, markdown, tmpParams) {
  233. const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
  234. // clone
  235. const params = Object.assign(tmpParams, {
  236. socketClientId: websocketContainer.getSocketClientId(),
  237. page_id: pageId,
  238. revision_id: revisionId,
  239. body: markdown,
  240. });
  241. const res = await this.appContainer.apiPost('/pages.update', params);
  242. if (!res.ok) {
  243. throw new Error(res.error);
  244. }
  245. return { page: res.page, tags: res.tags };
  246. }
  247. showSuccessToastr() {
  248. toastr.success(undefined, 'Saved successfully', {
  249. closeButton: true,
  250. progressBar: true,
  251. newestOnTop: false,
  252. showDuration: '100',
  253. hideDuration: '100',
  254. timeOut: '1200',
  255. extendedTimeOut: '150',
  256. });
  257. }
  258. showErrorToastr(error) {
  259. toastr.error(error.message, 'Error occured', {
  260. closeButton: true,
  261. progressBar: true,
  262. newestOnTop: false,
  263. showDuration: '100',
  264. hideDuration: '100',
  265. timeOut: '3000',
  266. });
  267. }
  268. addWebSocketEventHandlers() {
  269. const pageContainer = this;
  270. const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
  271. const socket = websocketContainer.getWebSocket();
  272. socket.on('page:create', (data) => {
  273. // skip if triggered myself
  274. if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
  275. return;
  276. }
  277. logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
  278. // update PageStatusAlert
  279. if (data.page.path === pageContainer.state.path) {
  280. this.setLatestRemotePageData(data.page, data.user);
  281. }
  282. });
  283. socket.on('page:update', (data) => {
  284. // skip if triggered myself
  285. if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
  286. return;
  287. }
  288. logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
  289. if (data.page.path === pageContainer.state.path) {
  290. // update PageStatusAlert
  291. pageContainer.setLatestRemotePageData(data.page, data.user);
  292. // update remote data
  293. const page = data.page;
  294. pageContainer.setState({
  295. remoteRevisionId: page.revision._id,
  296. revisionIdHackmdSynced: page.revisionHackmdSynced,
  297. hasDraftOnHackmd: page.hasDraftOnHackmd,
  298. });
  299. }
  300. });
  301. socket.on('page:delete', (data) => {
  302. // skip if triggered myself
  303. if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
  304. return;
  305. }
  306. logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
  307. // update PageStatusAlert
  308. if (data.page.path === pageContainer.state.path) {
  309. pageContainer.setLatestRemotePageData(data.page, data.user);
  310. }
  311. });
  312. socket.on('page:editingWithHackmd', (data) => {
  313. // skip if triggered myself
  314. if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
  315. return;
  316. }
  317. logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
  318. if (data.page.path === pageContainer.state.path) {
  319. pageContainer.setState({ isHackmdDraftUpdatingInRealtime: true });
  320. }
  321. });
  322. }
  323. }