PageContainer.js 11 KB

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