diff --git a/src/assets/logo.svg b/src/assets/logo.svg new file mode 100644 index 00000000..303f511a --- /dev/null +++ b/src/assets/logo.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/cledit/cleditCore.js b/src/cledit/cleditCore.js index d6174aee..bbf9f8cd 100644 --- a/src/cledit/cleditCore.js +++ b/src/cledit/cleditCore.js @@ -333,7 +333,9 @@ function cledit(contentElt, scrollElt, windowParam) { editor.init = function (options) { options = ({ - cursorFocusRatio: 0.2, + getCursorFocusRatio: function () { + return 0.1 + }, sectionHighlighter: function (section) { return section.text.replace(/&/g, '&').replace(/ cursorMaxY) { - scrollElt.scrollTop += this.cursorCoordinates.top + this.cursorCoordinates.height - cursorMaxY - } + var adjustment = scrollElt.clientHeight / 2 * editor.options.getCursorFocusRatio() + var cursorTop = this.cursorCoordinates.top + this.cursorCoordinates.height / 2 + var minScrollTop = cursorTop - adjustment + var maxScrollTop = cursorTop + adjustment - scrollElt.clientHeight + if (scrollElt.scrollTop > minScrollTop) { + scrollElt.scrollTop = minScrollTop + } else if (scrollElt.scrollTop < maxScrollTop) { + scrollElt.scrollTop = maxScrollTop } } adjustScroll = false diff --git a/src/components/App.vue b/src/components/App.vue index 17bafaf6..b243f648 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -1,24 +1,43 @@ diff --git a/src/components/ButtonBar.vue b/src/components/ButtonBar.vue index c9f06b5f..3e090b0b 100644 --- a/src/components/ButtonBar.vue +++ b/src/components/ButtonBar.vue @@ -1,10 +1,10 @@ diff --git a/src/components/Editor.vue b/src/components/Editor.vue index c6513285..c5c56e77 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -1,6 +1,6 @@ @@ -26,13 +26,13 @@ export default { } .editor__inner { + margin: 0; font-family: $font-family-main; font-variant-ligatures: no-common-ligatures; - margin: 0; - padding: 10px 20px 360px 110px; white-space: pre-wrap; word-break: break-word; word-wrap: break-word; + caret-color: #000; * { line-height: $line-height-base; diff --git a/src/components/Explorer.vue b/src/components/Explorer.vue index ee40efda..0005fa79 100644 --- a/src/components/Explorer.vue +++ b/src/components/Explorer.vue @@ -1,21 +1,23 @@ @@ -122,6 +165,10 @@ export default { diff --git a/src/components/NavigationBar.vue b/src/components/NavigationBar.vue index 88a784d5..89ae226f 100644 --- a/src/components/NavigationBar.vue +++ b/src/components/NavigationBar.vue @@ -1,12 +1,12 @@ diff --git a/src/components/common/app.scss b/src/components/common/app.scss index 221fcbae..81b80562 100644 --- a/src/components/common/app.scss +++ b/src/components/common/app.scss @@ -58,7 +58,7 @@ textarea { height: 36px; padding: 3px 12px; margin-bottom: 0; - font-size: inherit; + font-size: 18px; font-weight: 400; line-height: 1.4; overflow: hidden; @@ -76,14 +76,12 @@ textarea { border: 0; border-radius: $border-radius-base; - &:focus { + &:active, + &:focus, + &:hover { color: #333; - background-color: transparent; - - &:active, - & { - outline: 0; - } + background-color: rgba(0, 0, 0, 0.1); + outline: 0; } } @@ -107,3 +105,8 @@ textarea { flex-direction: column; } +.flex--space-between { + -webkit-justify-content: space-between; + justify-content: space-between; +} + diff --git a/src/data/defaultLocalSettings.js b/src/data/defaultLocalSettings.js new file mode 100644 index 00000000..2fb85e44 --- /dev/null +++ b/src/data/defaultLocalSettings.js @@ -0,0 +1,11 @@ +export default () => ({ + id: 'localSettings', + showNavigationBar: true, + showEditor: true, + showSidePreview: true, + showStatusBar: true, + showSideBar: false, + showExplorer: false, + focusMode: false, + updated: 0, +}); diff --git a/src/icons/Pen.vue b/src/icons/Pen.vue index 2e97a1c1..a8352cc3 100644 --- a/src/icons/Pen.vue +++ b/src/icons/Pen.vue @@ -1,5 +1,5 @@ diff --git a/src/icons/Target.vue b/src/icons/Target.vue new file mode 100644 index 00000000..ee458ba2 --- /dev/null +++ b/src/icons/Target.vue @@ -0,0 +1,5 @@ + diff --git a/src/icons/index.js b/src/icons/index.js index 894f5b28..3b8a788d 100644 --- a/src/icons/index.js +++ b/src/icons/index.js @@ -23,6 +23,7 @@ import Delete from './Delete'; import Close from './Close'; import FolderMultiple from './FolderMultiple'; import Pen from './Pen'; +import Target from './Target'; Vue.component('iconFormatBold', FormatBold); Vue.component('iconFormatItalic', FormatItalic); @@ -48,3 +49,4 @@ Vue.component('iconDelete', Delete); Vue.component('iconClose', Close); Vue.component('iconFolderMultiple', FolderMultiple); Vue.component('iconPen', Pen); +Vue.component('iconTarget', Target); diff --git a/src/services/editorSvc.js b/src/services/editorSvc.js index 9a31725e..ebf926d7 100644 --- a/src/services/editorSvc.js +++ b/src/services/editorSvc.js @@ -17,12 +17,12 @@ const debounce = cledit.Utils.debounce; const allowDebounce = (action, wait) => { let timeoutId; - return (doDebounce = false) => { + return (doDebounce = false, ...params) => { clearTimeout(timeoutId); if (doDebounce) { - timeoutId = setTimeout(() => action(), wait); + timeoutId = setTimeout(() => action(...params), wait); } else { - action(); + action(...params); } }; }; @@ -178,6 +178,12 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b this.parsingCtx = markdownConversionSvc.parseSections(this.converter, text); return this.parsingCtx.sections; }, + getCursorFocusRatio: () => { + if (store.getters['data/localSettings'].focusMode) { + return 1; + } + return 0.15; + }, }; editorEngineSvc.initClEditor(options, reinitClEditor); editorEngineSvc.clEditor.toggleEditable(true); @@ -321,10 +327,13 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b /** * Measure the height of each section in editor, preview and toc. */ - measureSectionDimensions: allowDebounce(() => { + measureSectionDimensions: allowDebounce((restoreScrollPosition) => { if (editorSvc.sectionDescList && this.sectionDescList !== editorSvc.sectionDescMeasuredList) { sectionUtils.measureSectionDimensions(editorSvc); editorSvc.sectionDescMeasuredList = editorSvc.sectionDescList; + if (restoreScrollPosition) { + editorSvc.restoreScrollPosition(); + } editorSvc.$emit('sectionDescMeasuredList', editorSvc.sectionDescMeasuredList); } }, 500), @@ -538,11 +547,6 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b input: Object.create(editorEngineSvc.clEditor), }); this.pagedownEditor.run(); - this.editorElt.addEventListener('focus', () => { - // if (clEditorLayoutSvc.currentControl === 'menu') { - // clEditorLayoutSvc.currentControl = undefined - // } - }); // state.pagedownEditor.hooks.set('insertLinkDialog', (callback) => { // clEditorSvc.linkDialogCallback = callback // clEditorLayoutSvc.currentControl = 'linkDialog' @@ -556,14 +560,13 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b // return true // }) - this.editorElt.addEventListener('scroll', () => this.saveContentState(true)); + this.editorElt.parentNode.addEventListener('scroll', () => this.saveContentState(true)); const refreshPreview = () => { this.convert(); if (instantPreview) { this.refreshPreview(); - this.measureSectionDimensions(); - this.restoreScrollPosition(); + this.measureSectionDimensions(false, true); } else { setTimeout(() => this.refreshPreview(), 10); } @@ -704,7 +707,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b // }, 1) // }) - // clEditorSvc.setPreviewElt(element[0].querySelector('.preview__inner')) + // clEditorSvc.setPreviewElt(element[0].querySelector('.preview__inner-2')) // var previewElt = element[0].querySelector('.preview') // clEditorSvc.isPreviewTop = previewElt.scrollTop < 10 // previewElt.addEventListener('scroll', function () { @@ -742,10 +745,8 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b immediate: true, }); - store.watch(() => `${store.getters['layout/styles'].editorWidth},${store.getters['layout/styles'].previewWidth}`, - () => editorSvc.measureSectionDimensions(true)); - store.watch(() => store.getters['layout/styles'].showSidePreview, - showSidePreview => showSidePreview && editorSvc.measureSectionDimensions()); + store.watch(() => store.getters['layout/styles'], + () => editorSvc.measureSectionDimensions(false, true)); }, }); diff --git a/src/services/localDbSvc.js b/src/services/localDbSvc.js index c53e4879..6b96eabb 100644 --- a/src/services/localDbSvc.js +++ b/src/services/localDbSvc.js @@ -18,7 +18,7 @@ if (window.shimIndexedDB && (!indexedDB || (navigator.userAgent.indexOf('Chrome' function getStorePrefixFromType(type) { // Return `files` for type `file`, `folders` for type `folder`, etc... const prefix = `${type}s`; - return store.state[prefix] && prefix; + return store.state[prefix] ? prefix : 'data'; } const deleteMarkerMaxAge = 1000; @@ -110,7 +110,7 @@ export default { /** * Return a promise that is resolved once the synchronization between the store and the localDb * is finished. Effectively, open a transaction, then read and apply all changes from the DB - * since previous transaction, then write all changes from the store. + * since the previous transaction, then write all the changes from the store. */ sync() { return new Promise((resolve) => { @@ -119,10 +119,14 @@ export default { store.state.contents, store.state.files, store.state.folders, + store.state.data, ].forEach(moduleState => Object.assign(storeItemMap, moduleState.itemMap)); this.connection.createTx((tx) => { this.readAll(storeItemMap, tx, () => { this.writeAll(storeItemMap, tx); + if (!store.state.ready) { + store.commit('setReady'); + } resolve(); }); }); diff --git a/src/services/optional/scrollSync.js b/src/services/optional/scrollSync.js index b8d8060b..bbb0cc58 100644 --- a/src/services/optional/scrollSync.js +++ b/src/services/optional/scrollSync.js @@ -166,13 +166,11 @@ editorSvc.$on('previewText', () => { }); store.watch( - () => store.getters['layout/styles'].showSidePreview, - (showSidePreview) => { - if (showSidePreview) { - isScrollEditor = true; - isScrollPreview = false; - skipAnimation = true; - } + () => store.getters['layout/styles'], + () => { + isScrollEditor = true; + isScrollPreview = false; + skipAnimation = true; }); store.watch( diff --git a/src/store/index.js b/src/store/index.js index fa2f3fc2..bab4df2b 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -4,22 +4,34 @@ import Vuex from 'vuex'; import contents from './modules/contents'; import files from './modules/files'; import folders from './modules/folders'; +import data from './modules/data'; import layout from './modules/layout'; import editor from './modules/editor'; import explorer from './modules/explorer'; +import modal from './modules/modal'; Vue.use(Vuex); const debug = process.env.NODE_ENV !== 'production'; const store = new Vuex.Store({ + state: { + ready: false, + }, + mutations: { + setReady: (state) => { + state.ready = true; + }, + }, modules: { contents, files, folders, + data, layout, editor, explorer, + modal, }, strict: debug, plugins: debug ? [createLogger()] : [], diff --git a/src/store/modules/data.js b/src/store/modules/data.js new file mode 100644 index 00000000..d4452e46 --- /dev/null +++ b/src/store/modules/data.js @@ -0,0 +1,41 @@ +import moduleTemplate from './moduleTemplate'; +import defaultLocalSettings from '../../data/defaultLocalSettings'; + +const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => { + dispatch('patchLocalSettings', { + [propertyName]: value === undefined ? !getters.localSettings[propertyName] : value, + }); +}; + +const module = moduleTemplate((id) => { + switch (id) { + case 'localSettings': + return defaultLocalSettings(); + default: + throw new Error(`Unknown data id ${id}`); + } +}); + +module.getters = { + ...module.getters, + localSettings: state => state.itemMap.localSettings || defaultLocalSettings(), +}; + +module.actions = { + ...module.actions, + patchLocalSettings({ getters, commit }, value) { + commit('patchOrSetItem', { + ...value, + id: 'localSettings', + }); + }, + toggleNavigationBar: localSettingsToggler('showNavigationBar'), + toggleEditor: localSettingsToggler('showEditor'), + toggleSidePreview: localSettingsToggler('showSidePreview'), + toggleStatusBar: localSettingsToggler('showStatusBar'), + toggleSideBar: localSettingsToggler('showSideBar'), + toggleExplorer: localSettingsToggler('showExplorer'), + toggleFocusMode: localSettingsToggler('focusMode'), +}; + +export default module; diff --git a/src/store/modules/explorer.js b/src/store/modules/explorer.js index 4dadc219..0df55d31 100644 --- a/src/store/modules/explorer.js +++ b/src/store/modules/explorer.js @@ -6,6 +6,14 @@ const setter = propertyName => (state, value) => { state[propertyName] = value; }; +function debounceAction(action, wait) { + let timeoutId; + return (context) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => action(context), wait); + }; +} + const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name); @@ -31,19 +39,29 @@ class Node { const nilFileNode = new Node(emptyFile()); nilFileNode.isNil = true; +const fakeFileNode = new Node(emptyFile()); +fakeFileNode.item.id = 'fake'; function getParent(node, getters) { - if (node === nilFileNode) { + if (node.isNil) { return nilFileNode; } return getters.nodeMap[node.item.parentId] || getters.rootNode; } +function getFolder(node, getters) { + return node.item.type === 'folder' ? + node : + getParent(node, getters); +} + export default { namespaced: true, state: { selectedId: null, editingId: null, + dragSourceId: null, + dragTargetId: null, newChildNode: nilFileNode, openNodes: {}, }, @@ -70,6 +88,8 @@ export default { } }); rootNode.sortChildren(); + // Add a fake file at the end of the root folder to always allow drag and drop into it. + rootNode.files.push(fakeFileNode); return { nodeMap, rootNode, @@ -79,19 +99,29 @@ export default { rootNode: (state, getters) => getters.nodeStructure.rootNode, newChildNodeParent: (state, getters) => getParent(state.newChildNode, getters), selectedNode: (state, getters) => getters.nodeMap[state.selectedId] || nilFileNode, - selectedNodeFolder: (state, getters) => { - const selectedNode = getters.selectedNode; - return selectedNode.item.type === 'folder' - ? selectedNode - : getParent(selectedNode, getters); - }, + selectedNodeFolder: (state, getters) => getFolder(getters.selectedNode, getters), editingNode: (state, getters) => getters.nodeMap[state.editingId] || nilFileNode, + dragSourceNode: (state, getters) => getters.nodeMap[state.dragSourceId] || nilFileNode, + dragTargetNode: (state, getters) => { + if (state.dragTargetId === 'fake') { + return fakeFileNode; + } + return getters.nodeMap[state.dragTargetId] || nilFileNode; + }, + dragTargetNodeFolder: (state, getters) => { + if (state.dragTargetId === 'fake') { + return getters.rootNode; + } + return getFolder(getters.dragTargetNode, getters); + }, }, mutations: { setSelectedId: setter('selectedId'), setEditingId: setter('editingId'), + setDragSourceId: setter('dragSourceId'), + setDragTargetId: setter('dragTargetId'), setNewItem(state, item) { - state.newChildNode = item ? new Node(item) : nilFileNode; + state.newChildNode = item ? new Node(item, item.type === 'folder') : nilFileNode; }, setNewItemName(state, name) { state.newChildNode.item.name = name; @@ -110,5 +140,12 @@ export default { dispatch('openNode', node.item.parentId); } }, + openDragTarget: debounceAction(({ state, dispatch }) => { + dispatch('openNode', state.dragTargetId); + }, 1000), + setDragTarget({ state, getters, commit, dispatch }, id) { + commit('setDragTargetId', id); + dispatch('openDragTarget'); + }, }, }; diff --git a/src/store/modules/layout.js b/src/store/modules/layout.js index 78fb0bb4..c788e3e6 100644 --- a/src/store/modules/layout.js +++ b/src/store/modules/layout.js @@ -1,5 +1,7 @@ const editorMinWidth = 280; const minPadding = 20; +const previewButtonWidth = 55; +const editorTopPadding = 10; const navigationBarSpaceWidth = 30; const navigationBarLeftWidth = 570; const maxTitleMaxWidth = 800; @@ -9,10 +11,6 @@ const setter = propertyName => (state, value) => { state[propertyName] = value; }; -const toggler = propertyName => (state, value) => { - state[propertyName] = value === undefined ? !state[propertyName] : value; -}; - export default { namespaced: true, state: { @@ -23,26 +21,12 @@ export default { buttonBarWidth: 30, statusBarHeight: 20, }, - // Configuration - showNavigationBar: true, - showEditor: true, - showSidePreview: true, - showStatusBar: true, - showSideBar: false, - showExplorer: true, editorWidthFactor: 1, fontSizeFactor: 1, - // Styles bodyWidth: 0, bodyHeight: 0, }, mutations: { - toggleNavigationBar: toggler('showNavigationBar'), - toggleEditor: toggler('showEditor'), - toggleSidePreview: toggler('showSidePreview'), - toggleStatusBar: toggler('showStatusBar'), - toggleSideBar: toggler('showSideBar'), - toggleExplorer: toggler('showExplorer'), setEditorWidthFactor: setter('editorWidthFactor'), setFontSizeFactor: setter('fontSizeFactor'), updateBodySize: (state) => { @@ -51,15 +35,16 @@ export default { }, }, getters: { - styles: (state) => { + styles: (state, getters, rootState, rootGetters) => { + const localSettings = rootGetters['data/localSettings']; const styles = { - showNavigationBar: !state.showEditor || state.showNavigationBar, - showStatusBar: state.showStatusBar, - showEditor: state.showEditor, - showSidePreview: state.showSidePreview && state.showEditor, - showPreview: state.showSidePreview || !state.showEditor, - showSideBar: state.showSideBar, - showExplorer: state.showExplorer, + showNavigationBar: !localSettings.showEditor || localSettings.showNavigationBar, + showStatusBar: localSettings.showStatusBar, + showEditor: localSettings.showEditor, + showSidePreview: localSettings.showSidePreview && localSettings.showEditor, + showPreview: localSettings.showSidePreview || !localSettings.showEditor, + showSideBar: localSettings.showSideBar, + showExplorer: localSettings.showExplorer, }; function computeStyles() { @@ -96,6 +81,7 @@ export default { if (styles.showSidePreview && doublePanelWidth / 2 < editorMinWidth) { styles.showSidePreview = false; + styles.showPreview = false; computeStyles(); return; } @@ -118,15 +104,24 @@ export default { } styles.fontSize *= state.fontSizeFactor; - const panelWidth = doublePanelWidth / 2; + const bottomPadding = Math.floor(styles.innerHeight / 2); + const panelWidth = Math.floor(doublePanelWidth / 2); styles.previewWidth = styles.showSidePreview ? panelWidth : styles.innerWidth; - styles.previewPadding = Math.max((styles.previewWidth - styles.textWidth) / 2, minPadding); + const previewLeftPadding = Math.max( + Math.floor((styles.previewWidth - styles.textWidth) / 2), minPadding); + let previewRightPadding = previewLeftPadding; + if (!styles.showEditor && previewRightPadding < previewButtonWidth) { + previewRightPadding = previewButtonWidth; + } + styles.previewPadding = `${editorTopPadding}px ${previewRightPadding}px ${bottomPadding}px ${previewLeftPadding}px`; styles.editorWidth = styles.showSidePreview ? panelWidth : doublePanelWidth; - styles.editorPadding = Math.max((styles.editorWidth - styles.textWidth) / 2, minPadding); + const editorSidePadding = Math.max( + Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding); + styles.editorPadding = `${editorTopPadding}px ${editorSidePadding}px ${bottomPadding}px`; styles.titleMaxWidth = styles.innerWidth - navigationBarSpaceWidth; if (styles.showEditor) { diff --git a/src/store/modules/modal.js b/src/store/modules/modal.js new file mode 100644 index 00000000..2351f891 --- /dev/null +++ b/src/store/modules/modal.js @@ -0,0 +1,49 @@ +const confirmButtons = yesText => [{ + text: 'No', +}, { + text: yesText || 'Yes', + resolve: true, +}]; + +export default { + namespaced: true, + state: { + content: null, + }, + mutations: { + setContent: (state, value) => { + state.content = value; + }, + }, + actions: { + open({ commit }, content) { + return new Promise((resolve, reject) => { + if (!content.buttons) { + content.buttons = [{ + text: 'OK', + resolve: true, + }]; + } + content.buttons.forEach((button) => { + button.onClick = () => { + commit('setContent'); + if (button.resolve) { + resolve(button.resolve); + } else { + reject(); + } + }; + }); + commit('setContent', content); + }); + }, + fileDeletion: ({ dispatch }, item) => dispatch('open', { + text: `

You are about to delete the file ${item.name}. Are you sure ?

`, + buttons: confirmButtons('Yes, delete'), + }), + folderDeletion: ({ dispatch }, item) => dispatch('open', { + text: `

You are about to delete the folder ${item.name} and all its files. Are you sure ?

`, + buttons: confirmButtons('Yes, delete'), + }), + }, +}; diff --git a/src/store/modules/moduleTemplate.js b/src/store/modules/moduleTemplate.js index 1577ee55..3de1b196 100644 --- a/src/store/modules/moduleTemplate.js +++ b/src/store/modules/moduleTemplate.js @@ -1,36 +1,45 @@ import Vue from 'vue'; -import utils from '../../services/utils'; -export default empty => ({ - namespaced: true, - state: { - itemMap: {}, - }, - getters: { - items: state => Object.keys(state.itemMap).map(key => state.itemMap[key]), - }, - mutations: { - setItem(state, value) { - const item = Object.assign(empty(), value); - if (!item.id) { - item.id = utils.uid(); - } - if (!item.updated) { - item.updated = Date.now(); - } +export default (empty) => { + function setItem(state, value) { + const item = Object.assign(empty(value.id), value); + if (!item.updated) { + item.updated = Date.now(); + } + Vue.set(state.itemMap, item.id, item); + } + + function patchItem(state, patch) { + const item = state.itemMap[patch.id]; + if (item) { + Object.assign(item, patch); + item.updated = Date.now(); // Trigger sync Vue.set(state.itemMap, item.id, item); + return true; + } + return false; + } + + return { + namespaced: true, + state: { + itemMap: {}, }, - patchItem(state, patch) { - const item = state.itemMap[patch.id]; - if (item) { - Object.assign(item, patch); - item.updated = Date.now(); // Trigger sync - Vue.set(state.itemMap, item.id, item); - } + getters: { + items: state => Object.keys(state.itemMap).map(key => state.itemMap[key]), }, - deleteItem(state, id) { - Vue.delete(state.itemMap, id); + mutations: { + setItem, + patchItem, + patchOrSetItem(state, patch) { + if (!patchItem(state, patch)) { + setItem(state, patch); + } + }, + deleteItem(state, id) { + Vue.delete(state.itemMap, id); + }, }, - }, - actions: {}, -}); + actions: {}, + }; +};