mirror of
https://github.com/BoostIo/Boostnote
synced 2025-12-12 17:26:17 +00:00
540 lines
15 KiB
JavaScript
540 lines
15 KiB
JavaScript
import PropTypes from 'prop-types'
|
|
import React from 'react'
|
|
import { push } from 'connected-react-router'
|
|
import CSSModules from 'browser/lib/CSSModules'
|
|
import dataApi from 'browser/main/lib/dataApi'
|
|
import styles from './SideNav.styl'
|
|
import { openModal } from 'browser/main/lib/modal'
|
|
import PreferencesModal from '../modals/PreferencesModal'
|
|
import ConfigManager from 'browser/main/lib/ConfigManager'
|
|
import StorageItem from './StorageItem'
|
|
import TagListItem from 'browser/components/TagListItem'
|
|
import SideNavFilter from 'browser/components/SideNavFilter'
|
|
import StorageList from 'browser/components/StorageList'
|
|
import NavToggleButton from 'browser/components/NavToggleButton'
|
|
import EventEmitter from 'browser/main/lib/eventEmitter'
|
|
import PreferenceButton from './PreferenceButton'
|
|
import ListButton from './ListButton'
|
|
import TagButton from './TagButton'
|
|
import { SortableContainer } from 'react-sortable-hoc'
|
|
import i18n from 'browser/lib/i18n'
|
|
import context from 'browser/lib/context'
|
|
import { remote } from 'electron'
|
|
import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote'
|
|
import ColorPicker from 'browser/components/ColorPicker'
|
|
import { every, sortBy } from 'lodash'
|
|
|
|
function matchActiveTags(tags, activeTags) {
|
|
return every(activeTags, v => tags.indexOf(v) >= 0)
|
|
}
|
|
|
|
class SideNav extends React.Component {
|
|
// TODO: should not use electron stuff v0.7
|
|
constructor(props) {
|
|
super(props)
|
|
|
|
this.state = {
|
|
colorPicker: {
|
|
show: false,
|
|
color: null,
|
|
tagName: null,
|
|
targetRect: null
|
|
}
|
|
}
|
|
|
|
this.dismissColorPicker = this.dismissColorPicker.bind(this)
|
|
this.handleColorPickerConfirm = this.handleColorPickerConfirm.bind(this)
|
|
this.handleColorPickerReset = this.handleColorPickerReset.bind(this)
|
|
}
|
|
|
|
componentDidMount() {
|
|
EventEmitter.on('side:preferences', this.handleMenuButtonClick)
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
EventEmitter.off('side:preferences', this.handleMenuButtonClick)
|
|
}
|
|
|
|
deleteTag(tag) {
|
|
const selectedButton = remote.dialog.showMessageBox(
|
|
remote.getCurrentWindow(),
|
|
{
|
|
type: 'warning',
|
|
message: i18n.__('Confirm tag deletion'),
|
|
detail: i18n.__('This will permanently remove this tag.'),
|
|
buttons: [i18n.__('Confirm'), i18n.__('Cancel')]
|
|
}
|
|
)
|
|
|
|
if (selectedButton === 0) {
|
|
const {
|
|
data,
|
|
dispatch,
|
|
location,
|
|
match: { params }
|
|
} = this.props
|
|
|
|
const notes = data.noteMap
|
|
.map(note => note)
|
|
.filter(note => note.tags.indexOf(tag) !== -1)
|
|
.map(note => {
|
|
note = Object.assign({}, note)
|
|
note.tags = note.tags.slice()
|
|
|
|
note.tags.splice(note.tags.indexOf(tag), 1)
|
|
|
|
return note
|
|
})
|
|
|
|
Promise.all(
|
|
notes.map(note => dataApi.updateNote(note.storage, note.key, note))
|
|
).then(updatedNotes => {
|
|
updatedNotes.forEach(note => {
|
|
dispatch({
|
|
type: 'UPDATE_NOTE',
|
|
note
|
|
})
|
|
})
|
|
|
|
if (location.pathname.match('/tags')) {
|
|
const tags = params.tagname.split(' ')
|
|
const index = tags.indexOf(tag)
|
|
if (index !== -1) {
|
|
tags.splice(index, 1)
|
|
|
|
dispatch(
|
|
push(
|
|
`/tags/${tags.map(tag => encodeURIComponent(tag)).join(' ')}`
|
|
)
|
|
)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
handleMenuButtonClick(e) {
|
|
openModal(PreferencesModal)
|
|
}
|
|
|
|
handleHomeButtonClick(e) {
|
|
const { dispatch } = this.props
|
|
dispatch(push('/home'))
|
|
}
|
|
|
|
handleStarredButtonClick(e) {
|
|
const { dispatch } = this.props
|
|
dispatch(push('/starred'))
|
|
}
|
|
|
|
handleTagContextMenu(e, tag) {
|
|
const menu = []
|
|
|
|
menu.push({
|
|
label: i18n.__('Delete Tag'),
|
|
click: this.deleteTag.bind(this, tag)
|
|
})
|
|
|
|
menu.push({
|
|
label: i18n.__('Customize Color'),
|
|
click: this.displayColorPicker.bind(
|
|
this,
|
|
tag,
|
|
e.target.getBoundingClientRect()
|
|
)
|
|
})
|
|
|
|
context.popup(menu)
|
|
}
|
|
|
|
dismissColorPicker() {
|
|
this.setState({
|
|
colorPicker: {
|
|
show: false
|
|
}
|
|
})
|
|
}
|
|
|
|
displayColorPicker(tagName, rect) {
|
|
const { config } = this.props
|
|
this.setState({
|
|
colorPicker: {
|
|
show: true,
|
|
color: config.coloredTags[tagName],
|
|
tagName,
|
|
targetRect: rect
|
|
}
|
|
})
|
|
}
|
|
|
|
handleColorPickerConfirm(color) {
|
|
const {
|
|
dispatch,
|
|
config: { coloredTags }
|
|
} = this.props
|
|
const {
|
|
colorPicker: { tagName }
|
|
} = this.state
|
|
const newColoredTags = Object.assign({}, coloredTags, {
|
|
[tagName]: color.hex
|
|
})
|
|
|
|
const config = { coloredTags: newColoredTags }
|
|
ConfigManager.set(config)
|
|
dispatch({
|
|
type: 'SET_CONFIG',
|
|
config
|
|
})
|
|
this.dismissColorPicker()
|
|
}
|
|
|
|
handleColorPickerReset() {
|
|
const {
|
|
dispatch,
|
|
config: { coloredTags }
|
|
} = this.props
|
|
const {
|
|
colorPicker: { tagName }
|
|
} = this.state
|
|
const newColoredTags = Object.assign({}, coloredTags)
|
|
|
|
delete newColoredTags[tagName]
|
|
|
|
const config = { coloredTags: newColoredTags }
|
|
ConfigManager.set(config)
|
|
dispatch({
|
|
type: 'SET_CONFIG',
|
|
config
|
|
})
|
|
this.dismissColorPicker()
|
|
}
|
|
|
|
handleToggleButtonClick(e) {
|
|
const { dispatch, config } = this.props
|
|
|
|
ConfigManager.set({ isSideNavFolded: !config.isSideNavFolded })
|
|
dispatch({
|
|
type: 'SET_IS_SIDENAV_FOLDED',
|
|
isFolded: !config.isSideNavFolded
|
|
})
|
|
}
|
|
|
|
handleTrashedButtonClick(e) {
|
|
const { dispatch } = this.props
|
|
dispatch(push('/trashed'))
|
|
}
|
|
|
|
handleSwitchFoldersButtonClick() {
|
|
const { dispatch } = this.props
|
|
dispatch(push('/home'))
|
|
}
|
|
|
|
handleSwitchTagsButtonClick() {
|
|
const { dispatch } = this.props
|
|
dispatch(push('/alltags'))
|
|
}
|
|
|
|
onSortEnd(storage) {
|
|
return ({ oldIndex, newIndex }) => {
|
|
const { dispatch } = this.props
|
|
dataApi.reorderFolder(storage.key, oldIndex, newIndex).then(data => {
|
|
dispatch({ type: 'REORDER_FOLDER', storage: data.storage })
|
|
})
|
|
}
|
|
}
|
|
|
|
SideNavComponent(isFolded, storageList) {
|
|
const { location, data, config } = this.props
|
|
|
|
const isHomeActive = !!location.pathname.match(/^\/home$/)
|
|
const isStarredActive = !!location.pathname.match(/^\/starred$/)
|
|
const isTrashedActive = !!location.pathname.match(/^\/trashed$/)
|
|
|
|
let component
|
|
|
|
// TagsMode is not selected
|
|
if (
|
|
!location.pathname.match('/tags') &&
|
|
!location.pathname.match('/alltags')
|
|
) {
|
|
component = (
|
|
<div>
|
|
<SideNavFilter
|
|
isFolded={isFolded}
|
|
isHomeActive={isHomeActive}
|
|
handleAllNotesButtonClick={e => this.handleHomeButtonClick(e)}
|
|
isStarredActive={isStarredActive}
|
|
isTrashedActive={isTrashedActive}
|
|
handleStarredButtonClick={e => this.handleStarredButtonClick(e)}
|
|
handleTrashedButtonClick={e => this.handleTrashedButtonClick(e)}
|
|
counterTotalNote={
|
|
data.noteMap._map.size - data.trashedSet._set.size
|
|
}
|
|
counterStarredNote={data.starredSet._set.size}
|
|
counterDelNote={data.trashedSet._set.size}
|
|
handleFilterButtonContextMenu={this.handleFilterButtonContextMenu.bind(
|
|
this
|
|
)}
|
|
/>
|
|
|
|
<StorageList storageList={storageList} isFolded={isFolded} />
|
|
<NavToggleButton
|
|
isFolded={isFolded}
|
|
handleToggleButtonClick={this.handleToggleButtonClick.bind(this)}
|
|
/>
|
|
</div>
|
|
)
|
|
} else {
|
|
component = (
|
|
<div styleName='tabBody'>
|
|
<div styleName='tag-control'>
|
|
<div styleName='tag-control-title'>
|
|
<p>{i18n.__('Tags')}</p>
|
|
</div>
|
|
<div styleName='tag-control-sortTagsBy'>
|
|
<i className='fa fa-angle-down' />
|
|
<select
|
|
styleName='tag-control-sortTagsBy-select'
|
|
title={i18n.__('Select filter mode')}
|
|
value={config.sortTagsBy}
|
|
onChange={e => this.handleSortTagsByChange(e)}
|
|
>
|
|
<option title='Sort alphabetically' value='ALPHABETICAL'>
|
|
{i18n.__('Alphabetically')}
|
|
</option>
|
|
<option title='Sort by update time' value='COUNTER'>
|
|
{i18n.__('Counter')}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div styleName='tagList'>{this.tagListComponent(data)}</div>
|
|
<NavToggleButton
|
|
isFolded={isFolded}
|
|
handleToggleButtonClick={this.handleToggleButtonClick.bind(this)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return component
|
|
}
|
|
|
|
tagListComponent() {
|
|
const { data, location, config } = this.props
|
|
const { colorPicker } = this.state
|
|
const activeTags = this.getActiveTags(location.pathname)
|
|
const relatedTags = this.getRelatedTags(activeTags, data.noteMap)
|
|
let tagList = sortBy(
|
|
data.tagNoteMap
|
|
.map((tag, name) => ({
|
|
name,
|
|
size: tag.size,
|
|
related: relatedTags.has(name)
|
|
}))
|
|
.filter(tag => tag.size > 0),
|
|
['name']
|
|
)
|
|
if (config.ui.enableLiveNoteCounts && activeTags.length !== 0) {
|
|
const notesTags = data.noteMap.map(note => note.tags)
|
|
tagList = tagList.map(tag => {
|
|
tag.size = notesTags.filter(
|
|
tags => tags.includes(tag.name) && matchActiveTags(tags, activeTags)
|
|
).length
|
|
return tag
|
|
})
|
|
}
|
|
if (config.sortTagsBy === 'COUNTER') {
|
|
tagList = sortBy(tagList, item => 0 - item.size)
|
|
}
|
|
if (config.ui.showOnlyRelatedTags && relatedTags.size > 0) {
|
|
tagList = tagList.filter(tag => tag.related)
|
|
}
|
|
return tagList.map(tag => {
|
|
return (
|
|
<TagListItem
|
|
name={tag.name}
|
|
handleClickTagListItem={this.handleClickTagListItem.bind(this)}
|
|
handleClickNarrowToTag={this.handleClickNarrowToTag.bind(this)}
|
|
handleContextMenu={this.handleTagContextMenu.bind(this)}
|
|
isActive={
|
|
this.getTagActive(location.pathname, tag.name) ||
|
|
colorPicker.tagName === tag.name
|
|
}
|
|
isRelated={tag.related}
|
|
key={tag.name}
|
|
count={tag.size}
|
|
color={config.coloredTags[tag.name]}
|
|
/>
|
|
)
|
|
})
|
|
}
|
|
|
|
getRelatedTags(activeTags, noteMap) {
|
|
if (activeTags.length === 0) {
|
|
return new Set()
|
|
}
|
|
const relatedNotes = noteMap
|
|
.map(note => ({ key: note.key, tags: note.tags }))
|
|
.filter(note => activeTags.every(tag => note.tags.includes(tag)))
|
|
const relatedTags = new Set()
|
|
relatedNotes.forEach(note => note.tags.map(tag => relatedTags.add(tag)))
|
|
return relatedTags
|
|
}
|
|
|
|
getTagActive(path, tag) {
|
|
return this.getActiveTags(path).includes(tag)
|
|
}
|
|
|
|
getActiveTags(path) {
|
|
const pathSegments = path.split('/')
|
|
const tags = pathSegments[pathSegments.length - 1]
|
|
return tags === 'alltags' ? [] : decodeURIComponent(tags).split(' ')
|
|
}
|
|
|
|
handleClickTagListItem(name) {
|
|
const { dispatch } = this.props
|
|
dispatch(push(`/tags/${encodeURIComponent(name)}`))
|
|
}
|
|
|
|
handleSortTagsByChange(e) {
|
|
const { dispatch } = this.props
|
|
|
|
const config = {
|
|
sortTagsBy: e.target.value
|
|
}
|
|
|
|
ConfigManager.set(config)
|
|
dispatch({
|
|
type: 'SET_CONFIG',
|
|
config
|
|
})
|
|
}
|
|
|
|
handleClickNarrowToTag(tag) {
|
|
const { dispatch, location } = this.props
|
|
const listOfTags = this.getActiveTags(location.pathname)
|
|
const indexOfTag = listOfTags.indexOf(tag)
|
|
if (indexOfTag > -1) {
|
|
listOfTags.splice(indexOfTag, 1)
|
|
} else {
|
|
listOfTags.push(tag)
|
|
}
|
|
dispatch(push(`/tags/${encodeURIComponent(listOfTags.join(' '))}`))
|
|
}
|
|
|
|
emptyTrash(entries) {
|
|
const { dispatch } = this.props
|
|
const deletionPromises = entries.map(note => {
|
|
return dataApi.deleteNote(note.storage, note.key)
|
|
})
|
|
const { confirmDeletion } = this.props.config.ui
|
|
if (!confirmDeleteNote(confirmDeletion, true)) return
|
|
Promise.all(deletionPromises)
|
|
.then(arrayOfStorageAndNoteKeys => {
|
|
arrayOfStorageAndNoteKeys.forEach(({ storageKey, noteKey }) => {
|
|
dispatch({ type: 'DELETE_NOTE', storageKey, noteKey })
|
|
})
|
|
})
|
|
.catch(err => {
|
|
console.error('Cannot Delete note: ' + err)
|
|
})
|
|
}
|
|
|
|
handleFilterButtonContextMenu(event) {
|
|
const { data } = this.props
|
|
const trashedNotes = data.trashedSet
|
|
.toJS()
|
|
.map(uniqueKey => data.noteMap.get(uniqueKey))
|
|
context.popup([
|
|
{
|
|
label: i18n.__('Empty Trash'),
|
|
click: () => this.emptyTrash(trashedNotes)
|
|
}
|
|
])
|
|
}
|
|
|
|
render() {
|
|
const { data, location, config, dispatch } = this.props
|
|
const { colorPicker: colorPickerState } = this.state
|
|
|
|
const isFolded = config.isSideNavFolded
|
|
|
|
const storageList = data.storageMap.map((storage, key) => {
|
|
const SortableStorageItem = SortableContainer(StorageItem)
|
|
return (
|
|
<SortableStorageItem
|
|
key={storage.key}
|
|
storage={storage}
|
|
data={data}
|
|
location={location}
|
|
isFolded={isFolded}
|
|
dispatch={dispatch}
|
|
onSortEnd={this.onSortEnd.bind(this)(storage)}
|
|
useDragHandle
|
|
/>
|
|
)
|
|
})
|
|
|
|
let colorPicker
|
|
if (colorPickerState.show) {
|
|
colorPicker = (
|
|
<ColorPicker
|
|
color={colorPickerState.color}
|
|
targetRect={colorPickerState.targetRect}
|
|
onConfirm={this.handleColorPickerConfirm}
|
|
onCancel={this.dismissColorPicker}
|
|
onReset={this.handleColorPickerReset}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const style = {}
|
|
if (!isFolded) style.width = this.props.width
|
|
const isTagActive = /tag/.test(location.pathname)
|
|
return (
|
|
<div
|
|
className='SideNav'
|
|
styleName={isFolded ? 'root--folded' : 'root'}
|
|
tabIndex='1'
|
|
style={style}
|
|
>
|
|
<div styleName='top'>
|
|
<div styleName='switch-buttons'>
|
|
<ListButton
|
|
onClick={this.handleSwitchFoldersButtonClick.bind(this)}
|
|
isTagActive={isTagActive}
|
|
/>
|
|
<TagButton
|
|
onClick={this.handleSwitchTagsButtonClick.bind(this)}
|
|
isTagActive={isTagActive}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<PreferenceButton onClick={this.handleMenuButtonClick} />
|
|
</div>
|
|
</div>
|
|
{this.SideNavComponent(isFolded, storageList)}
|
|
{colorPicker}
|
|
</div>
|
|
)
|
|
}
|
|
}
|
|
|
|
SideNav.contextTypes = {
|
|
router: PropTypes.shape({})
|
|
}
|
|
|
|
SideNav.propTypes = {
|
|
dispatch: PropTypes.func,
|
|
storages: PropTypes.array,
|
|
config: PropTypes.shape({
|
|
isSideNavFolded: PropTypes.bool
|
|
}),
|
|
location: PropTypes.shape({
|
|
pathname: PropTypes.string
|
|
})
|
|
}
|
|
|
|
export default CSSModules(SideNav, styles)
|