`s), and enables (multitouch) interactions like dragging and resizing items.
46 | * Knows nothing of an item's content, except its `docId` given by the top level app. It simply passes this `docId` to the configured component (`StemItem` in this app), so the top level app decides what to draw inside an item.
47 |
48 | ### Storage ([`src/storage`](src/storage))
49 | * Keeps a persistent collection of documents (currently just simple webmarks and text notes, e.g. `{url: 'https://webmemex.org'}`), and a collection links between them (simple `{sourceDocId, targetDocId}` pairs).
50 | * Not to be confused with the Redux store (`src/store.js`), which manages the application state, and thus also contains the (non-persistent) canvas state.
51 | * Storage is currently implemented as part of the redux store, using [`redux-pouchdb`](https://github.com/vicentedealencar/redux-pouchdb) for synchronising its state in Redux with a [PouchDB] database in the browser's offline storage.
52 |
53 |
54 | [React]: https://facebook.github.io/react
55 | [Redux]: http://redux.js.org
56 | [PouchDB]: https://pouchdb.com/
57 | [Node]: https://nodejs.org
58 |
--------------------------------------------------------------------------------
/src/canvas/components/Canvas.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { connect } from 'react-redux'
3 | import { bindActionCreators } from 'redux'
4 | import classNames from 'classnames'
5 |
6 | import AnimatedItemContainer from './AnimatedItemContainer'
7 | import Edge from './Edge'
8 | import * as actions from '../actions'
9 |
10 | let Canvas = React.createClass({
11 |
12 | componentDidMount() {
13 | window.addEventListener('resize', this.props.updateWindowSize)
14 | this.props.updateWindowSize()
15 |
16 | window.addEventListener('keydown', (event)=>{
17 | if (event.keyCode==27) {
18 | this.props.unexpand()
19 | if (event.target === this.refs['canvas']
20 | || event.target === document.body) {
21 | this.props.handleEscape()
22 | }
23 | }
24 | })
25 |
26 | this.refs['canvas'].addEventListener('touchstart', e=>e.preventDefault())
27 |
28 | this.enableDrop()
29 | },
30 |
31 | render() {
32 | let {ItemComponent, canvasSize, visibleItems, edges, showDropSpace, unexpand} = this.props
33 | let handleTap = event => {
34 | if (this.props.expandedItem)
35 | this.props.unexpand()
36 | else
37 | this.props.handleTap({x: event.pageX, y: event.pageY})
38 | event.stopPropagation()
39 | }
40 | const className = classNames({showDropSpace})
41 | return (
42 |
43 |
48 | {
49 | Object.keys(visibleItems).map(itemId => (
50 |
55 | ))
56 | }
57 |
58 | )
59 | },
60 |
61 | enableDrop() {
62 | const canvasEl = this.refs['canvas']
63 | canvasEl.ondragover = event => event.preventDefault()
64 |
65 | canvasEl.ondragenter = event => {
66 | event.preventDefault()
67 | let x = event.clientX
68 | let y = event.clientY
69 | this.props.handleDragEnter({x, y, event})
70 | }
71 |
72 | canvasEl.ondrop = event => {
73 | event.stopPropagation()
74 | event.preventDefault()
75 | let x = event.clientX // TODO compute coordinates relative to canvas
76 | let y = event.clientY
77 | this.props.handleDrop({x, y, event})
78 | this.props.handleDragLeave({x, y, event})
79 | }
80 | },
81 | })
82 |
83 |
84 | function mapStateToProps(state) {
85 | state = state.canvas // TODO make us get only this namespace
86 | return {
87 | canvasSize: state.canvasSize,
88 | visibleItems: state.visibleItems,
89 | edges: state.edges,
90 | expandedItem: state.expandedItem,
91 | showDropSpace: state.showDropSpace,
92 | }
93 | }
94 |
95 | function mapDispatchToProps(dispatch) {
96 | let dispatchUpdateWindowSize = () => dispatch(actions.updateWindowSize({
97 | width: window.innerWidth,
98 | height: window.innerHeight,
99 | }))
100 |
101 | let dispatchUnexpand = () => dispatch(actions.unexpand({animate: true}))
102 |
103 | return {
104 | updateWindowSize: dispatchUpdateWindowSize,
105 | unexpand: dispatchUnexpand,
106 | ...bindActionCreators({
107 | unfocus: actions.unfocus,
108 | handleDrop: actions.signalDropOnCanvas,
109 | handleDragEnter: actions.handleDragEnter,
110 | handleDragLeave: actions.handleDragLeave,
111 | handleTap: actions.signalCanvasTapped,
112 | handleEscape: actions.signalEscape,
113 | }, dispatch)
114 | }
115 | }
116 |
117 | export default connect(mapStateToProps, mapDispatchToProps)(Canvas)
118 |
--------------------------------------------------------------------------------
/app/assets/style.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | /* By Christian Urff, from https://fontlibrary.org/en/font/grundschrift */
3 | font-family: 'Grundschrift';
4 | src: url('GrundschriftNormal.otf') format('opentype');
5 | font-weight: normal;
6 | font-style: normal;
7 | }
8 |
9 | html, body {
10 | height: 100%;
11 | }
12 |
13 | body {
14 | margin: 0;
15 | }
16 |
17 | body::-webkit-scrollbar {
18 | display: none;
19 | }
20 |
21 | #app-container {
22 | width: 100%;
23 | height: 100%;
24 | /*overflow-y: hidden;*/ /* Causes scrollLeft to be zero. Chromium bug? */
25 | }
26 |
27 | #canvas {
28 | position: relative;
29 | width: 100%;
30 | height: 100%;
31 | background-color: #F2F2F2;
32 | background-image: url(background.jpg);
33 | background-size: cover;
34 | overflow: hidden;
35 | }
36 |
37 | svg#edges {
38 | position: absolute;
39 | top: 0;
40 | left: 0;
41 | width:100%;
42 | height:100%;
43 | pointer-events: visible;
44 | }
45 |
46 | .edge {
47 | stroke: #ccc;
48 | stroke-width: 10px;
49 | }
50 |
51 | .item-container {
52 | border-radius: 10px;
53 | background: #fafafa;
54 | position: absolute;
55 | box-shadow: 3px 3px 10px #888;
56 | }
57 |
58 | .item-container.expanded {
59 | z-index: 1;
60 | transition: transform 0.4s;
61 | transform-origin: left;
62 | }
63 | #canvas.showDropSpace .item-container.expanded {
64 | transform: scale(0.7, 1);
65 | }
66 |
67 | .item-container > .button {
68 | border: none;
69 | cursor: pointer;
70 | position: absolute;
71 | bottom: 2px;
72 | right: 2px;
73 | background: transparent;
74 | border-radius: 4px;
75 | padding: 4px;
76 | opacity: 0.4;
77 | }
78 |
79 | .item-container > .button:hover {
80 | opacity: 1;
81 | background-color: #fff;
82 | }
83 |
84 | .emptyItem {
85 | border: 2px solid #ddd;
86 | box-sizing: border-box;
87 | border-radius: 10px;
88 | margin: 0;
89 | padding: 0;
90 | width: 100%;
91 | height: 100%;
92 | }
93 |
94 | .emptyItem.notelike {
95 | background-color: #FFD;
96 | }
97 | .emptyItem.notelike .emptyItemInput {
98 | font-size: 18px;
99 | font-family: Grundschrift, sans-serif;
100 | }
101 |
102 | .emptyItem.urllike .emptyItemInput {
103 | font-family: mono;
104 | }
105 |
106 | .emptyItemForm {
107 | padding-top: 17px;
108 | }
109 |
110 | .emptyItemForm .emptyItemInput {
111 | background-color: transparent;
112 | width: 90%;
113 | height: 22px;
114 | margin-left: 5%;
115 | box-sizing: border-box;
116 | border: none;
117 | outline: none;
118 | font-family: mono;
119 | font-size: 14px;
120 | }
121 |
122 | .emptyItemForm ul.react-autosuggest__suggestions-container {
123 | padding-left: 20px;
124 | padding-right: 20px;
125 | list-style: none;
126 | }
127 |
128 | .emptyItemForm li.react-autosuggest__suggestion {
129 | margin-top: 1px;
130 | background-color: #fff;
131 | border: 1px solid transparent;
132 | box-shadow: 3px 3px 10px #888;
133 | max-height: 50px;
134 | overflow: hidden;
135 | }
136 |
137 | .emptyItemForm li.react-autosuggest__suggestion:last-child {
138 | border-radius: 0 0 7px 7px;
139 | }
140 |
141 | .emptyItemForm li.react-autosuggest__suggestion--focused {
142 | background-color: #eef;
143 | border: 1px solid #99f;
144 | }
145 |
146 | .autosuggestion {
147 | padding: 5px;
148 | overflow: hidden;
149 | white-space: nowrap;
150 | text-overflow: ellipsis;
151 | }
152 |
153 | .autosuggestion_note {
154 | background-color: #ffd;
155 | font-family: Grundschrift, sans-serif;
156 | width: 100%;
157 | box-sizing: border-box;
158 | }
159 |
160 | .emptyItemForm li.react-autosuggest__suggestion--focused .autosuggestion_note {
161 | background-color: #ff6;
162 | }
163 |
164 | .autosuggestion_url {
165 | font-family: monospace;
166 | }
167 |
168 | .note {
169 | overflow: auto;
170 | border-radius: 10px;
171 | background-color: #FFD;
172 | width: 100%;
173 | height: 100%;
174 | padding: 20px;
175 | box-sizing: border-box;
176 | font-size: 20px;
177 | font-family: Grundschrift, sans-serif;
178 | }
179 |
180 | .note:focus {
181 | outline: none;
182 | }
183 |
184 | .webpage-placeholder {
185 | box-sizing: border-box;
186 | overflow: hidden;
187 | max-height: 100%;
188 | max-width: 100%;
189 | padding: 2px;
190 | color: lightgrey;
191 | font-family: sans-serif;
192 | font-size: small;
193 | }
194 |
195 | .webpage-iframe-wrapper-container {
196 | border-radius: 10px;
197 | position: relative;
198 | overflow: hidden;
199 | }
200 |
201 | .webpage-iframe-scaling-container {
202 | transform-origin: 0 0;
203 | }
204 |
205 | .webpage-iframe {
206 | width: 100%;
207 | height: 100%;
208 | }
209 |
210 | .webpage-iframe-overlay {
211 | position: absolute;
212 | width: 100%;
213 | height: 100%;
214 | top: 0;
215 | left: 0;
216 | opacity: .0;
217 | }
218 |
219 |
220 | iframe[seamless] {
221 | background-color: transparent;
222 | border: 0px none transparent;
223 | padding: 0px;
224 | overflow: hidden;
225 | }
226 |
227 | .note::-webkit-scrollbar
228 | {
229 | width: 4px;
230 | height: 0px;
231 | }
232 | .note::-webkit-scrollbar-track
233 | {
234 | background: transparent;
235 | }
236 | .note::-webkit-scrollbar-thumb
237 | {
238 | background: rgba(255, 177, 0, 0.5);
239 | }
240 |
--------------------------------------------------------------------------------
/src/components/EmptyItem.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { bindActionCreators } from 'redux'
3 | import { connect } from 'react-redux'
4 | import Autosuggest from 'react-autosuggest'
5 | import classNames from 'classnames'
6 |
7 | import * as actions from '../actions'
8 | import { getEmptyItemState, getAutoSuggestSuggestions } from '../selectors'
9 | import { asUrl } from '../utils'
10 |
11 | const placeholders = {
12 | 'emptyItem_alone': 'Find or add new note/webpage...',
13 | 'emptyItem_linkto': 'Create link to...',
14 | 'emptyItem_linkfrom': 'Create link from...',
15 | }
16 |
17 | let EmptyItem = React.createClass({
18 |
19 | render() {
20 | const submitForm = event => {
21 | event.preventDefault()
22 | let inputValue = this.props.inputValue.trim()
23 | if (inputValue) {
24 | this.props.submitForm(inputValue)
25 | }
26 | }
27 | const placeholder = placeholders[this.props.docId]
28 | const inputProps = {
29 | value: this.props.inputValue,
30 | type: 'text',
31 | placeholder,
32 | className: 'emptyItemInput',
33 | onFocus: () => this.props.focus(),
34 | onBlur: () => {
35 | this.props.blur()
36 | if (this.props.hideOnBlur && this.props.inputValue==='')
37 | this.props.hide()
38 | },
39 | onChange: (e, {newValue}) => this.props.changed(newValue),
40 | }
41 | const renderSuggestion = suggestion => {
42 | let html = suggestion.inputValueCompletion
43 | let classes = `autosuggestion autosuggestion_${suggestion.type}`
44 | return
45 | }
46 | const getSuggestionValue = s => s.inputValueCompletion
47 | const onSuggestionSelected = (event, {suggestion}) => {
48 | event.preventDefault()
49 | this.props.pickSuggestion(suggestion)
50 | }
51 |
52 | let hasInput = (this.props.inputValue && this.props.inputValue.length > 0)
53 | let inputIsUrl = hasInput && (asUrl(this.props.inputValue) !== undefined)
54 |
55 | let itemClass = classNames(
56 | 'emptyItem',
57 | {
58 | 'urllike': inputIsUrl,
59 | 'notelike': hasInput && !inputIsUrl,
60 | }
61 | )
62 |
63 | return (
64 |
76 | )
77 | },
78 |
79 | componentDidMount() {
80 | this.updateBrowserFocus()
81 | let el = this.refs['form'].getElementsByClassName('emptyItemInput')[0]
82 | el.addEventListener('keydown', (event)=>{
83 | if (event.keyCode==27) {
84 | el.blur();
85 | }
86 | })
87 | },
88 | componentDidUpdate(oldProps) {
89 | this.updateBrowserFocus()
90 | },
91 |
92 | updateBrowserFocus() {
93 | // Make browser state reflect application state.
94 | let el = this.refs['form'].getElementsByClassName('emptyItemInput')[0]
95 | if (this.props.focussed && document.activeElement !== el) {
96 | el.focus()
97 | }
98 | if (!this.props.focussed && document.activeElement === el) {
99 | el.blur()
100 | }
101 | }
102 |
103 | })
104 |
105 |
106 | function mapStateToProps(state, {canvasItemId}) {
107 | let itemState = getEmptyItemState(state, canvasItemId)
108 | let inputValue = itemState !== undefined ? itemState.inputValue || '' : ''
109 | let inputValueForSuggestions = itemState !== undefined ? itemState.inputValueForSuggestions : ''
110 | let suggestions = itemState !== undefined
111 | ? getAutoSuggestSuggestions(state, itemState.inputValueForSuggestions)
112 | : []
113 | return {
114 | suggestions,
115 | inputValue,
116 | hideOnBlur: itemState && itemState.hideOnBlur,
117 | inputValueForSuggestions,
118 | }
119 | }
120 |
121 | function mapDispatchToProps(dispatch, {canvasItemId}) {
122 | return {
123 | submitForm: userInput => {
124 | dispatch(actions.setEmptyItemState({
125 | itemId: canvasItemId,
126 | props: {inputValue: ''},
127 | }))
128 | dispatch(actions.navigateTo({userInput, itemId: canvasItemId}))
129 | },
130 | pickSuggestion: suggestion => {
131 | dispatch(actions.setEmptyItemState({
132 | itemId: canvasItemId,
133 | props: {inputValue: ''},
134 | }))
135 | if (suggestion.docId) {
136 | dispatch(actions.navigateTo({docId: suggestion.docId, itemId: canvasItemId}))
137 | }
138 | },
139 | hide: () => {
140 | dispatch(canvas.hideItem({itemId: canvasItemId}))
141 | dispatch(actions.setEmptyItemState({itemId: canvasItemId, props: {inputValue: undefined, inputValueForSuggestions: undefined, hideOnBlur: undefined}}))
142 | },
143 | ...bindActionCreators({
144 | changed: inputValue => actions.setEmptyItemState({
145 | itemId: canvasItemId,
146 | props: {inputValue}
147 | }),
148 | updateAutoSuggest: () => actions.updateAutoSuggest({itemId: canvasItemId}),
149 | }, dispatch)
150 | }
151 | }
152 |
153 | export default connect(mapStateToProps, mapDispatchToProps)(EmptyItem)
154 |
--------------------------------------------------------------------------------
/src/canvas/reducer.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import { createReducer } from 'redux-act'
3 |
4 | import * as actions from './actions'
5 | import { getItem, getItemIdForDocId } from './selectors'
6 | import { reuseOrGenerateItemId } from './utils'
7 |
8 | const defaultState = {
9 | // Some unimportant initial sizes, which are updated when loaded.
10 | // TODO can we remove canvas and/or window size from state?
11 | canvasSize: {
12 | width: 800,
13 | height: 600,
14 | },
15 | windowSize: {
16 | width: 800,
17 | height: 600,
18 | },
19 | // No items or edges yet.
20 | visibleItems: {
21 | // [itemId]: {docId, x, y, width, height}
22 | },
23 | edges: {
24 | // [edgeId]: {linkId, sourceItemId, targetItemId}
25 | },
26 | expandedItem: undefined, // itemId
27 | centeredItem: undefined, // itemId
28 | focussedItem: undefined, // itemId
29 | showDropSpace: false,
30 | }
31 |
32 | const ITEM_MIN_WIDTH = 100
33 | const ITEM_MIN_HEIGHT = 20
34 |
35 | // Note: this function is almost like a reducer, but it also returns a value.
36 | // It is equivalent to actions.createItem, but for usage within other reducers.
37 | function createItem(state, {docId, props}) {
38 | let itemId = reuseOrGenerateItemId(state, {docId})
39 | state = createItemWithId(state, {itemId, docId, props})
40 | return {state, itemId}
41 | }
42 |
43 | function createItemWithId(state, {itemId, docId, props}) {
44 | let newItem = {
45 | ...props,
46 | docId,
47 | flaggedForRemoval: undefined,
48 | }
49 | let visibleItems = {...state.visibleItems, [itemId]: newItem}
50 | return {...state, visibleItems}
51 | }
52 |
53 | function changeDoc(state, {itemId, docId}) {
54 | let item = getItem(state, itemId)
55 | item = {...item, docId}
56 | state = {...state, visibleItems: {...state.visibleItems, [itemId]: item}}
57 | return state
58 | }
59 |
60 | function centerItem(state, {itemId, animate}, {currentView}) {
61 | state = relocateItem(state, {itemId, xRelative: 0.5, yRelative: 0.5, animate}, {currentView})
62 | let item = getItem(state, itemId)
63 | let newItem = {...item, inTransition: animate, centered: true}
64 |
65 | let visibleItems = {...state.visibleItems, [itemId]: newItem}
66 | return {...state, visibleItems, centeredItem: itemId}
67 | }
68 |
69 | function centerDocWithFriends(state, {
70 | docId, itemId, targetDocIds, sourceDocIds, targetsTargets,
71 | sourcesSources, animate
72 | }, {currentView}) {
73 | // Flag all items, so items not reused will be removed further below.
74 | state = flagAllItemsForRemoval(state)
75 | // Do not keep old edges, remove them.
76 | state = {...state, edges: {}}
77 | if (itemId !== undefined) {
78 | // Caller specified which item should be in the center. Prevent removal.
79 | let item = getItem(state, itemId)
80 | item = {...item, flaggedForRemoval: undefined}
81 | let visibleItems = {...state.visibleItems, [itemId]: item}
82 | state = {...state, visibleItems}
83 | }
84 | else {
85 | // No item was passed, create a new item for the doc
86 | let {state: newState, itemId: newItemId} = createItem(state, {docId})
87 | state = newState
88 | itemId = newItemId
89 | }
90 |
91 | // Let it fill 1/3 of window width, fix width/height ratio to 4:3
92 | let width = state.windowSize.width/3
93 | let height = width*3/4
94 | state = resizeItem(state, {itemId, width, height, animate})
95 |
96 | // Center it
97 | state = centerItem(state, {itemId, animate}, {currentView})
98 |
99 | // Show its friends (linked items) left and right of it
100 | state = showItemFriends(state, {
101 | itemId,
102 | friendDocIds: targetDocIds,
103 | side: 'right',
104 | animate,
105 | }, {currentView})
106 | state = showItemFriends(state, {
107 | itemId,
108 | friendDocIds: sourceDocIds,
109 | side: 'left',
110 | animate,
111 | }, {currentView})
112 |
113 | _.forEach(targetDocIds, (docId) => {
114 | let itemId = getItemIdForDocId(state, docId)
115 | state = showItemFriends(state, {
116 | itemId,
117 | friendDocIds: targetsTargets[docId] || [],
118 | side: 'right',
119 | animate
120 | })
121 | })
122 | _.forEach(sourceDocIds, (docId) => {
123 | let itemId = getItemIdForDocId(state, docId)
124 | state = showItemFriends(state, {
125 | itemId,
126 | friendDocIds: sourcesSources[docId] || [],
127 | side: 'left',
128 | animate
129 | })
130 | })
131 |
132 | // Remove the items that have not been reused.
133 | state = removeFlaggedItems(state)
134 |
135 | return {...state}
136 | }
137 |
138 | function flagAllItemsForRemoval(state) {
139 | let visibleItems = _.mapValues(state.visibleItems,
140 | item => ({...item, flaggedForRemoval: true}))
141 | state = {...state, visibleItems}
142 | return state
143 | }
144 |
145 | function removeFlaggedItems(state) {
146 | let flaggedItems = _.pickBy(state.visibleItems,
147 | item => item.flaggedForRemoval)
148 | _.forOwn(flaggedItems, (item, itemId) => {
149 | state = hideItem(state, {itemId})
150 | })
151 | return state
152 | }
153 |
154 | function removeAllItems(state) {
155 | state = {...state,
156 | visibleItems: {},
157 | edges: {},
158 | expandedItem: undefined,
159 | centeredItem: undefined,
160 | focussedItem: undefined,
161 | }
162 | return state
163 | }
164 |
165 | function pruneEdges(state) {
166 | // Remove edges to or from non-existing items
167 | let visibleItems = state.visibleItems
168 | let edges = _.pickBy(
169 | state.edges,
170 | edge => ( edge.sourceItemId in visibleItems
171 | && edge.targetItemId in visibleItems)
172 | )
173 | return {...state, edges}
174 | }
175 |
176 | function showItemFriends(state, {itemId, friendDocIds, friendItemIds, side='right', animate}) {
177 | // If we were passed the docIds, show the docs and get the resulting itemIds
178 | let friendsToRelocate = []
179 | if (friendItemIds === undefined) {
180 | friendItemIds = []
181 | friendDocIds.forEach((friendDocId) => {
182 | // If the item is already in view, do not move it nor draw yet another one.
183 | let friendItemId = _.findKey(state.visibleItems, item => (item.docId === friendDocId && !item.flaggedForRemoval))
184 | if (!friendItemId) {
185 | // Create the item, or reuse one about to be thrown away.
186 | let {state: state_, itemId: friendItemId_} = createItem(state, {docId: friendDocId})
187 | state = state_, friendItemId = friendItemId_
188 | friendsToRelocate.push(friendItemId)
189 | }
190 | friendItemIds.push(friendItemId)
191 | })
192 | }
193 | else {
194 | friendsToRelocate = friendItemIds
195 | }
196 |
197 | // Position the friends besides the given item
198 | let newItems = {}
199 | let item = getItem(state, itemId)
200 | let nFriends = friendItemIds.length
201 | friendsToRelocate.forEach((friendItemId, index) => {
202 | let friendItem = getItem(state, friendItemId)
203 | // Spacing between friends and item (and between other friends)
204 | let width = item.width/2
205 | // Default to 3/4 ratio for now. Squeeze the items if there are too many.
206 | let height = Math.min(width*3/4, item.height/nFriends) // Note: ignores marginY
207 | let marginX = width/4
208 | let marginY = height/4
209 | // Put the friends in a column either right or left of the item
210 | let x = item.x + ((side == 'left') ? -(width+marginX) : item.width+marginX)
211 | // Create a column, symmetrically up and down from the item's midline
212 | let y = item.y + item.height/2 - height/2 + (index - (nFriends-1)/2) * (height + marginY)
213 | let newItem = {
214 | ...friendItem,
215 | x,
216 | y,
217 | width,
218 | height,
219 | inTransition: animate,
220 | }
221 | newItems[friendItemId] = newItem
222 | })
223 |
224 | let newEdges = {} // TODO edges should know their linkId, and be drawn automatically?
225 | friendItemIds.forEach(friendItemId => {
226 | newEdges[itemId+friendItemId] = {sourceItemId: itemId, targetItemId: friendItemId}
227 | })
228 |
229 | let visibleItems = {...state.visibleItems, ...newItems}
230 | let edges = {...state.edges, ...newEdges}
231 | return {...state, visibleItems, edges}
232 | }
233 |
234 |
235 | function hideEdge(state, {edgeId, itemId1, itemId2}) {
236 | if (edgeId === undefined) {
237 | edgeId = _.findKey(state.edges, edge => (
238 | (edge.sourceItemId === itemId1 && edge.targetItemId === itemId2)
239 | || (edge.sourceItemId === itemId2 && edge.targetItemId === itemId1)
240 | ))
241 | console.log(edgeId)
242 | }
243 | let edges = _.omit(state.edges, edgeId)
244 | return {...state, edges}
245 | }
246 |
247 | function showEdge(state, {linkId, sourceItemId, targetItemId}) {
248 | let edgeId = 'edge_'+linkId
249 | return {...state, edges: {...state.edges, [edgeId]: {linkId, sourceItemId, targetItemId}}}
250 | }
251 |
252 | function hideItem(state, {itemId}) {
253 | // Make shallow copy of state
254 | state = {...state}
255 |
256 | // Hide the item
257 | state.visibleItems = _.omit(state.visibleItems, itemId)
258 |
259 | // Clean up state
260 | if (state.centeredItem === itemId)
261 | state.centeredItem = undefined
262 | if (state.expandedItem === itemId)
263 | state.expandedItem = undefined
264 | if (state.focussedItem === itemId)
265 | state.focussedItem = undefined
266 |
267 | // Hide edges to/from the item
268 | state.edges = _.omitBy(
269 | state.edges,
270 | edge => (edge.sourceItemId===itemId || edge.targetItemId===itemId)
271 | )
272 |
273 | return state
274 | }
275 |
276 | function updateWindowSize(state, {height, width}, {currentView}) {
277 | // Reflect new window size in our state.
278 | let newWindowSize = {height, width}
279 | let newCanvasSize = {
280 | height: newWindowSize.height-1, // -1 to prevent scrollbars in Firefox. A rounding issue?
281 | width: newWindowSize.width-1,
282 | }
283 | state = {...state, windowSize: newWindowSize, canvasSize: newCanvasSize}
284 | // Update expanded item, if any.
285 | if (state.expandedItem) { // TODO this should not be here.. how to make it implicitly reactive?
286 | state = expandItem(state, {itemId: state.expandedItem, animate: false}, {currentView})
287 | }
288 | return state
289 | }
290 |
291 | function relocateItem(state, {itemId, x, y, dx, dy, xRelative, yRelative, animate}, {currentView}) {
292 | let item = getItem(state, itemId)
293 | if (xRelative!==undefined) {
294 | let winWidth = state.windowSize.width
295 | x = currentView.scrollX + winWidth*xRelative - item.width/2
296 | }
297 | if (yRelative!==undefined) {
298 | let winHeight = state.windowSize.height
299 | y = currentView.scrollY + winHeight*yRelative - item.height/2
300 | }
301 | if (x===undefined)
302 | x = item.x + dx
303 | if (y===undefined)
304 | y = item.y + dy
305 | let newItem = {...item, x, y, inTransition: animate}
306 | return {...state, visibleItems: {...state.visibleItems, [itemId]: newItem}}
307 | }
308 |
309 | function resizeItem(state, {itemId, width, height, dwidth, dheight, animate}) {
310 | // Resize to given width&height, or adjust size with dwidth&dheight
311 | let item = getItem(state, itemId)
312 |
313 | if (width===undefined)
314 | width = Math.max(item.width + dwidth, ITEM_MIN_WIDTH)
315 | if (height===undefined)
316 | height = Math.max(item.height + dheight, ITEM_MIN_HEIGHT)
317 | let newItem = {...item, width, height, inTransition: animate}
318 | return {...state, visibleItems: {...state.visibleItems, [itemId]: newItem}}
319 | }
320 |
321 | function setItemRatio(state, {itemId, ratio, keepFixed, animate}) {
322 | let item = getItem(state, itemId)
323 | let width = item.width
324 | let height = item.height
325 | if (keepFixed===undefined) {
326 | // Keep surface area constant
327 | let area = item.width * item.height
328 | width = Math.sqrt(area * ratio)
329 | height = width / ratio
330 | }
331 | else if (keepFixed==='height') {
332 | width = item.height * ratio
333 | }
334 | else if (keepFixed==='width') {
335 | height = item.width / ratio
336 | }
337 |
338 | return resizeItem(state, {itemId, width, height, animate})
339 | }
340 |
341 | function scaleItem(state, {itemId, dscale, origin, animate}) {
342 | let item = getItem(state, itemId)
343 | let oldWidth = item.width
344 | let oldHeight = item.height
345 | let newWidth = oldWidth * (1+dscale)
346 | let newHeight = oldHeight * (1+dscale)
347 |
348 | // Limit size to something arbitrary but reasonable
349 | let maxWidth = state.canvasSize.width
350 | let maxHeight = state.canvasSize.height
351 | if ( newWidth < ITEM_MIN_WIDTH && oldWidth > ITEM_MIN_WIDTH
352 | || newHeight < ITEM_MIN_HEIGHT && oldHeight > ITEM_MIN_HEIGHT
353 | || newWidth > maxWidth && oldWidth < maxWidth
354 | || newHeight > maxHeight && oldHeight < maxHeight)
355 | return state
356 |
357 | // Move the item to keep the specified origin point at the same spot.
358 | // (item-relative, so giving origin={x:0, y:0} pins the top-left corner)
359 | let {x, y} = origin
360 | let maxX = state.canvasSize.width - newWidth
361 | let maxY = state.canvasSize.height - newHeight
362 | let newX = Math.min(Math.max(item.x - x*dscale, 0), maxX)
363 | let newY = Math.min(Math.max(item.y - y*dscale, 0), maxY)
364 |
365 | let newItem = {...item, width: newWidth, height: newHeight, x: newX, y: newY, inTransition: animate}
366 | return {...state, visibleItems: {...state.visibleItems, [itemId]: newItem}}
367 | }
368 |
369 | function expandItem(state, {itemId, animate}, {currentView}) {
370 | if (state.expandedItem) {
371 | state = unexpand(state, {animate})
372 | }
373 |
374 | // Remember previous position, to restore when unexpanding
375 | let item = getItem(state, itemId)
376 | let oldPosition = {
377 | width: item.width,
378 | height: item.height,
379 | x: item.x,
380 | y: item.y,
381 | }
382 |
383 | // Fill most of the window
384 | let maxWidth = state.windowSize.width
385 | let maxHeight = state.windowSize.height
386 | let width = 0.95 * maxWidth
387 | let height = 0.95 * maxHeight
388 |
389 | // Put it in currently viewed area (in case canvas is bigger than window)
390 | let {scrollX, scrollY} = currentView
391 | let x = scrollX + state.windowSize.width/2 - width/2
392 | let y = scrollY + state.windowSize.height/2 - height/2
393 |
394 | let newItem = {...item, x, y, width, height, oldPosition, expanded: true, inTransition: animate}
395 | return {...state, visibleItems: {...state.visibleItems, [itemId]: newItem}, expandedItem: itemId}
396 | }
397 |
398 | function unexpand(state, {animate}) {
399 | let itemId = state.expandedItem
400 | if (itemId === undefined) {
401 | return state
402 | }
403 | let item = getItem(state, itemId)
404 |
405 | // Return to previously stored position
406 | let newItem = {...item, ...item.oldPosition, oldPosition:undefined,
407 | expanded: false, inTransition: animate}
408 | let visibleItems = {...state.visibleItems, [itemId]: newItem}
409 | return {...state, visibleItems, expandedItem: undefined}
410 | }
411 |
412 | function setItemDragged(state, {itemId, value}) {
413 | let item = getItem(state, itemId)
414 | let newItem = {...item, beingDragged: value}
415 | let visibleItems = {...state.visibleItems, [itemId]: newItem}
416 | return {...state, visibleItems}
417 | }
418 |
419 | function handleDragEnter(state, {}) {
420 | return {...state, showDropSpace: true}
421 | }
422 | function handleDragLeave(state, {}) {
423 | return {...state, showDropSpace: false}
424 | }
425 |
426 | function focusItem(state, {itemId}) {
427 | return {...state, focussedItem: itemId}
428 | }
429 |
430 | function unfocus(state, {itemId}) {
431 | if (itemId === undefined || state.focussedItem === itemId) {
432 | state = {...state, focussedItem: undefined}
433 | }
434 | return state
435 | }
436 |
437 | function setProps(state, {itemId, props}) {
438 | let item = getItem(state, itemId)
439 | let newItem = {...item, ...props}
440 | let visibleItems = {...state.visibleItems, [itemId]: newItem}
441 | return {...state, visibleItems}
442 | }
443 |
444 |
445 | const reducer = createReducer(
446 | {
447 | [actions.createItemWithId]: createItemWithId,
448 | [actions.changeDoc]: changeDoc,
449 | [actions.centerItem]: centerItem,
450 | [actions.centerDocWithFriends]: centerDocWithFriends,
451 | [actions.removeAllItems]: removeAllItems,
452 | [actions.showItemFriends]: showItemFriends,
453 | [actions.hideEdge]: hideEdge,
454 | [actions.showEdge]: showEdge,
455 | [actions.hideItem]: hideItem,
456 | [actions.updateWindowSize]: updateWindowSize,
457 | [actions.relocateItem]: relocateItem,
458 | [actions.resizeItem]: resizeItem,
459 | [actions.setItemRatio]: setItemRatio,
460 | [actions.scaleItem]: scaleItem,
461 | [actions.expandItem]: expandItem,
462 | [actions.unexpand]: unexpand,
463 | [actions.focusItem]: focusItem,
464 | [actions.unfocus]: unfocus,
465 | [actions.setProps]: setProps,
466 | [actions.setItemDragged]: setItemDragged,
467 | [actions.handleDragEnter]: handleDragEnter,
468 | [actions.handleDragLeave]: handleDragLeave,
469 | },
470 | defaultState
471 | )
472 |
473 | export default function canvasReducer(state, action) {
474 | state = reducer(state, action)
475 | // Hacky workaround: after any action that is not dragging, we stop dragging.
476 | // (because it seems hard to reliably notice when dragging has ended).
477 | if (action.type !== actions.handleDragEnter.getType()) {
478 | state = {...state, showDropSpace: false}
479 | }
480 | return state
481 | }
482 |
--------------------------------------------------------------------------------
/src/actions.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import { createAction } from 'redux-act'
3 | import ReduxQuerySync from 'redux-query-sync'
4 |
5 | import canvas from './canvas'
6 | import storage from './storage'
7 | import { getEmptyItemState } from './selectors'
8 | import { asUrl, textToHtml } from './utils'
9 |
10 | // Clean the canvas and show an empty item
11 | export function initCanvas() {
12 | return function(dispatch, getState) {
13 | // Only run when canvas is still empty (to ignore second, fallback invocation - see main.jsx)
14 | if (!_.isEmpty(getState().canvas.visibleItems))
15 | return
16 |
17 | // Add and show welcome message + friends for demo purposes.
18 | {
19 | // Add some notes, pages and links
20 | {
21 | const welcomeMessage = (
22 | 'Hi! This is a read/write web browser. '
23 | + 'It lets you
create notes and links, to organise the web your way. '
24 | + 'Watch the video linked to this note, or just try it out!'
25 | + '
'
26 | + 'Try enter a URL in the bar above to load a webpage, '
27 | + 'or enter text to create a note. '
28 | + 'Or use it to find back any of your pages¬es.'
29 | + '
'
30 | + 'To add links to an item, use the input boxes besides it. '
31 | + 'Also, try following a normal link inside a web page: it is added automatically!'
32 | )
33 |
34 | const usageNotes = (
35 | '
Usage notes'
36 | + '- Try browsing blogs, Wikipedia, etcetera; interactive sites/webapps may sputter.
'
37 | + '- Your notes and links are stored in your browser\'s local storage. Do not trust it to persist forever.
'
38 | + '- Ctrl/Cmd+tap/click opens a webpage in a new tab.
'
39 | + '- Ctrl+Shift+tap/click deletes an item or link.
'
40 | + '- Try drag some text, image or url onto the canvas to add it. Also works nicely for quotes selected from webpages!
'
41 | )
42 |
43 | const aboutNote = (
44 | '
More about this project'
45 | + 'Have a look at the items linked to this note to read/watch the ideas behind it, and to find the source code of this demo.'
46 | + '
'
47 | + 'This particular experiment (a "browser in a browser") is no longer developed, '
48 | + 'but the next incarnation of the idea is in full progress!'
49 | )
50 |
51 | // Store as notes, specifying docId to overwrite any older ones.
52 | dispatch(storage.addNote({docId: 'welcomeMessage', text: welcomeMessage}))
53 | dispatch(storage.addNote({docId: 'usageNotes', 'text': usageNotes}))
54 | dispatch(storage.addNote({docId: 'aboutNote', 'text': aboutNote}))
55 |
56 | const demoDocs = {
57 | 'demoDoc_rwweb': 'https://web.archive.org/web/20160303135717id_/http://read-write-web.org/',
58 | 'demoDoc_www': 'https://www.w3.org/People/Berners-Lee/WorldWideWeb.html',
59 | 'demoDoc_proposal': 'https://www.w3.org/History/1989/proposal.html',
60 | 'demoDoc_aswemaythink': 'http://www.theatlantic.com/magazine/archive/1945/07/as-we-may-think/303881/',
61 | 'demoDoc_iannotatetalk': 'https://www.youtube.com/embed/vKzYmDUydTw',
62 | 'demoDoc_webmemexsource': 'https://github.com/WebMemex/webmemex-demo',
63 | 'demoDoc_screencast': 'http://demo.webmemex.org/assets/demovideo.html',
64 | 'demoDoc_webmemex': 'https://webmemex.org',
65 | }
66 |
67 | const demoLinks = [
68 | {source: 'welcomeMessage', target: 'demoDoc_screencast'},
69 | {source: 'welcomeMessage', target: 'usageNotes'},
70 | {source: 'welcomeMessage', target: 'aboutNote'},
71 | {source: 'aboutNote', target: 'demoDoc_rwweb'},
72 | {source: 'demoDoc_rwweb', target: 'demoDoc_www'},
73 | {source: 'demoDoc_rwweb', target: 'demoDoc_proposal'},
74 | {source: 'demoDoc_rwweb', target: 'demoDoc_aswemaythink'},
75 | {source: 'aboutNote', target: 'demoDoc_iannotatetalk'},
76 | {source: 'aboutNote', target: 'demoDoc_webmemexsource'},
77 | {source: 'aboutNote', target: 'demoDoc_webmemex'},
78 | ]
79 |
80 | _.forEach(demoDocs, (url, docId) => {
81 | dispatch(storage.addUrl({url, docId}))
82 | })
83 | _.forEach(demoLinks, link => {
84 | dispatch(storage.addLink({
85 | linkId: `demoLink_${link.source}_${link.target}`,
86 | ...link,
87 | }))
88 | })
89 | }
90 |
91 | // Keep centered item in sync with URL query parameter.
92 | const getCurrentDocId = state => {
93 | const currentItem = canvas.getCenteredItem(state.canvas)
94 | return currentItem ? currentItem.docId : undefined
95 | }
96 | ReduxQuerySync({
97 | store: window.store, // XXX Using a global variable to access redux store. How to do this instead?
98 | initialTruth: 'location',
99 | params: {
100 | page: {
101 | selector: getCurrentDocId,
102 | action: docId => showDocIfExists({docId}),
103 | },
104 | }
105 | })
106 |
107 | // If no page was requested, show the welcome message.
108 | if (!canvas.getCenteredItem(getState().canvas)) {
109 | dispatch(drawStar({docId: 'welcomeMessage'}))
110 | }
111 | }
112 |
113 | }
114 | }
115 |
116 | function showDocIfExists({docId}) {
117 | return function (dispatch, getState) {
118 | try {
119 | storage.getDoc(getState().storage, docId)
120 | } catch (err) {
121 | return
122 | }
123 | dispatch(drawStar({docId}))
124 | }
125 | }
126 |
127 | // Put a doc in the center of view, with its linked docs around it.
128 | // Accepts either a docId or ItemId.
129 | export function drawStar({docId, itemId}) {
130 | return function (dispatch, getState) {
131 | let state = getState()
132 | if (docId === undefined) {
133 | docId = canvas.getItem(state.canvas, itemId).docId
134 | }
135 | let {targetDocIds, sourceDocIds} = storage.getFriends(state.storage, docId)
136 |
137 | // Add input entries for linking items
138 | targetDocIds.push('emptyItem_linkto')
139 | sourceDocIds.push('emptyItem_linkfrom')
140 |
141 |
142 | // Show second level friends
143 | const targetsTargets = {}
144 | targetDocIds.forEach(docId => {
145 | targetsTargets[docId] = storage.getFriends(getState().storage, docId).targetDocIds
146 | })
147 | const sourcesSources = {}
148 | sourceDocIds.forEach(docId => {
149 | sourcesSources[docId] = storage.getFriends(getState().storage, docId).sourceDocIds
150 | })
151 |
152 | dispatch(canvas.centerDocWithFriends({
153 | docId, itemId, targetDocIds, sourceDocIds,
154 | targetsTargets, sourcesSources, animate: true
155 | }))
156 |
157 |
158 | {
159 | let props = {x: 10, y: 10, width: 400, height: 55}
160 | let itemId3 = dispatch(canvas.createItem({docId: 'emptyItem_alone', props}))
161 | dispatch(canvas.relocateItem({itemId: itemId3, xRelative: 0.5, yRelative: 0.05}))
162 | }
163 | }
164 | }
165 |
166 | export function navigateFromLink({url}) {
167 | return function (dispatch, getState) {
168 | let docId = dispatch(storage.findOrAddUrl({url}))
169 | let width = 200
170 | let height = 150
171 | let props = {x: 10, y: 10, width, height}
172 | let itemId = dispatch(canvas.createItem({docId, props}))
173 | let expandedItem = getState().canvas.expandedItem
174 | if (expandedItem) {
175 | let expandedDocId = canvas.getItem(getState().canvas, expandedItem).docId
176 | dispatch(canvas.unexpand({animate: true}))
177 | dispatch(storage.findOrAddLink({
178 | source: expandedDocId,
179 | target: docId,
180 | type: 'followed',
181 | }))
182 | }
183 | dispatch(drawStar({itemId}))
184 | if (expandedItem) {
185 | // Appear to move in from the right side
186 | dispatch(canvas.relocateItem({itemId, xRelative: 0.8, yRelative: 0.5}))
187 | // Set end location at center (only relevant when unexpanding later)
188 | dispatch(canvas.centerItem({itemId, animate: true}))
189 | // Expand to fill whole canvas
190 | dispatch(canvas.expandItem({itemId, animate: true}))
191 | }
192 | dispatch(canvas.focusItem({itemId}))
193 |
194 | }
195 | }
196 |
197 | // Pass either docId or userInput
198 | export function navigateTo({itemId, docId, userInput}) {
199 | return function (dispatch, getState) {
200 | let centeredItem = getState().canvas.centeredItem
201 | let emptyItem = canvas.getItem(getState().canvas, itemId)
202 | dispatch(populateEmptyItem({itemId, docId, userInput}))
203 | if (centeredItem===itemId) {
204 | // This empty was the centered item, draw its star around it
205 | dispatch(drawStar({itemId}))
206 | }
207 | else {
208 | dispatch(canvas.setItemRatio({itemId, ratio: 4/3, keepFixed: 'width', animate: true}))
209 | if (centeredItem &&
210 | (emptyItem.docId==='emptyItem_linkto' || emptyItem.docId==='emptyItem_linkfrom')
211 | ) {
212 | // Redraw the star to move the new item to the correct place,
213 | // and pop up a new empty item.
214 | dispatch(drawStar({itemId: centeredItem}))
215 | }
216 | else {
217 | // This item becomes the new centered item
218 | dispatch(drawStar({itemId}))
219 | }
220 | }
221 | dispatch(canvas.focusItem({itemId}))
222 | }
223 | }
224 |
225 | function populateEmptyItem({itemId, docId, userInput}) {
226 | return function (dispatch, getState) {
227 | if (docId===undefined) {
228 | // Find or create the entered webpage/note
229 | docId = dispatch(findOrCreateDoc({userInput}))
230 | }
231 | // Show the document in the given item
232 | dispatch(canvas.changeDoc({itemId, docId}))
233 | // Link the new doc to any items it has edges to
234 | dispatch(linkToConnectedItems({itemId, docId}))
235 | }
236 | }
237 |
238 | function findOrCreateDoc({userInput}) {
239 | return function (dispatch, getState) {
240 | let docId
241 | // If it looks like a URL, we treat it like one.
242 | let url = asUrl(userInput)
243 | if (url) {
244 | docId = dispatch(storage.findOrAddUrl({url}))
245 | }
246 | else {
247 | docId = dispatch(storage.findOrAddNote({text: userInput}))
248 | }
249 | return docId
250 | }
251 | }
252 |
253 | function linkToConnectedItems({itemId, docId}) {
254 | return function(dispatch, getState) {
255 | let connectedItemIds = canvas.getConnectedItemIds(getState().canvas, itemId)
256 | connectedItemIds.forEach(connectedItemId => {
257 | let item = canvas.getItem(getState().canvas, itemId)
258 | let connectedItem = canvas.getItem(getState().canvas, connectedItemId)
259 |
260 | // Determine which is item is left and which right
261 | let docIsLeft = (item.x+item.width/2) < (connectedItem.x+connectedItem.width/2)
262 |
263 | // Create the link
264 | dispatch(storage.findOrAddLink({
265 | source: docIsLeft ? docId : connectedItem.docId,
266 | target: docIsLeft ? connectedItem.docId : docId,
267 | type: 'manual',
268 | }))
269 | })
270 | }
271 | }
272 |
273 | export function openInNewTab({itemId, docId}) {
274 | return function (dispatch, getState) {
275 | let state = getState()
276 | if (docId===undefined) {
277 | docId = canvas.getItem(state.canvas, itemId).docId
278 | }
279 | let doc = storage.getDoc(state.storage, docId)
280 | if (doc.url) {
281 | window.open(doc.url, '_blank')
282 | }
283 | }
284 | }
285 |
286 | export function handleDropOnCanvas({x, y, event}) {
287 | return function (dispatch, getState) {
288 | let html = event.dataTransfer.getData('text/html')
289 | let text = event.dataTransfer.getData('text')
290 | let url = event.dataTransfer.getData('URL') || asUrl(text)
291 | let docId
292 | if (url) {
293 | // Quick fix for removing proxy prefix from links and images dragged from our own iframes
294 | url = url.replace(new RegExp(`^(https?:)?\/\/${window.location.host}\/[a-z-]+\/(im_\/)?`), '')
295 | docId = dispatch(storage.findOrAddUrl({url}))
296 | }
297 | else if (html) {
298 | html = html.replace(String.fromCharCode(0), '') // work around some bug/feature(?) of Chromium
299 | docId = dispatch(storage.findOrAddNote({text: html}))
300 | }
301 | else if (text) {
302 | let html = textToHtml(text)
303 | docId = dispatch(storage.findOrAddNote({text: html}))
304 | }
305 | if (docId) {
306 | let width = 200
307 | let height = 150
308 | let props = {x: x-width/2, y: y-height/2, width, height}
309 | let itemId = dispatch(canvas.createItem({docId, props}))
310 | let centeredItemId = getState().canvas.centeredItem
311 | if (centeredItemId) {
312 | let onLeftHalf = x < getState().canvas.windowSize.width/2
313 | let sourceItemId = onLeftHalf ? itemId : centeredItemId
314 | let targetItemId = onLeftHalf ? centeredItemId : itemId
315 | dispatch(connectItems({sourceItemId, targetItemId}))
316 | setTimeout(()=>{
317 | // Give user a moment to click the dropped item; if they do not,
318 | // move it to its place in the star (but keep any expanded item expanded).
319 | if (getState().canvas.centeredItem === centeredItemId) {
320 | let expandedItem = getState().canvas.expandedItem
321 | dispatch(drawStar({itemId: centeredItemId}))
322 | if (expandedItem) {
323 | dispatch(canvas.expandItem({itemId: expandedItem, animate: false}))
324 | }
325 | }
326 | }, 1000)
327 | }
328 | }
329 | }
330 | }
331 |
332 | export function handleTapCanvas({x, y}) {
333 | return function (dispatch, getState) {
334 | }
335 | }
336 |
337 | export function handleTapEdge({event, sourceItemId, targetItemId}) {
338 | return function (dispatch, getState) {
339 | if (event.shiftKey) {
340 | let sourceDocId = canvas.getItem(getState().canvas, sourceItemId).docId
341 | let targetDocId = canvas.getItem(getState().canvas, targetItemId).docId
342 | dispatch(canvas.hideEdge({itemId1: sourceItemId, itemId2: targetItemId}))
343 | dispatch(storage.deleteLink({
344 | doc1: sourceDocId,
345 | doc2: targetDocId,
346 | }))
347 | }
348 | }
349 | }
350 |
351 | export function handleTapItem({itemId, event}) {
352 | return function (dispatch, getState) {
353 | if (event.shiftKey && (event.ctrlKey || event.metaKey)) {
354 | let item = canvas.getItem(getState().canvas, itemId)
355 | let friends = canvas.getConnectedItemIds(getState().canvas, itemId)
356 | let centeredItem = getState().canvas.centeredItem
357 | if (window.confirm("Delete this item?")) {
358 | dispatch(disconnectAndRemoveItem({itemId}))
359 | }
360 | return
361 | }
362 | if (event.ctrlKey || event.metaKey) {
363 | dispatch(openInNewTab({itemId}))
364 | return
365 | }
366 | // Focus on the item
367 | dispatch(canvas.focusItem({itemId}))
368 |
369 | let item = canvas.getItem(getState().canvas, itemId)
370 | if (item.docId.startsWith('emptyItem'))
371 | return
372 | if (item.centered) {
373 | // Only expand iframe items (TODO make simple type test)
374 | if (storage.getDoc(getState().storage, item.docId).url)
375 | dispatch(canvas.expandItem({itemId, animate: true}))
376 | }
377 | else {
378 | dispatch(drawStar({itemId}))
379 | }
380 | }
381 | }
382 |
383 | export function handleDraggedOut({itemId, dir}) {
384 | return function (dispatch, getState) {
385 | if (dir==='left' || dir==='right') { // No difference for now
386 | dispatch(disconnectAndRemoveItem({itemId}))
387 | }
388 |
389 | }
390 | }
391 |
392 | export function disconnectAndRemoveItem({itemId}) {
393 | return function (dispatch, getState) {
394 | let item = canvas.getItem(getState().canvas, itemId)
395 | let docId = item.docId
396 |
397 | // Hide 'Create link to/from...' inputs if this was the centered item
398 | let centeredItemId = getState().canvas.centeredItem
399 | if (centeredItemId === itemId) {
400 | let emptyItem1, emptyItem2
401 | try {
402 | emptyItem1 = canvas.getItemIdForDocId(getState().canvas, 'emptyItem_linkto')
403 | } catch (e) {}
404 | if (emptyItem1) {
405 | dispatch(canvas.hideItem({itemId: emptyItem1}))
406 | }
407 | try {
408 | emptyItem2 = canvas.getItemIdForDocId(getState().canvas, 'emptyItem_linkfrom')
409 | } catch (e) {}
410 | if (emptyItem2) {
411 | dispatch(canvas.hideItem({itemId: emptyItem2}))
412 | }
413 | }
414 |
415 | // Remove the item's _visible_ links from storage
416 | let connectedItemIds = canvas.getConnectedItemIds(getState().canvas, itemId)
417 | connectedItemIds.forEach(connectedItemId => {
418 | let connectedDocId = canvas.getItem(getState().canvas, connectedItemId).docId
419 | dispatch(storage.deleteLink({
420 | doc1: connectedDocId,
421 | doc2: docId,
422 | }))
423 | })
424 |
425 | // Hide the item from the canvas
426 | dispatch(canvas.hideItem({itemId}))
427 |
428 | // Delete jettisoned doc completely if it is left unconnected
429 | if (!storage.hasFriends(getState().storage, docId)) {
430 | dispatch(storage.deleteDoc({docId}))
431 | }
432 |
433 | let remainingItems = Object.keys(getState().canvas.visibleItems)
434 | if (remainingItems.length === 1) {
435 | dispatch(canvas.centerItem({itemId: remainingItems[0], animate: true}))
436 | }
437 | }
438 | }
439 |
440 | export function updateAutoSuggest({itemId}) {
441 | return function(dispatch, getState) {
442 | // Tell UI to show suggestions for the current input
443 | dispatch(updateEmptyItemSuggestions({itemId}))
444 | // Let store search for suggestions
445 | let inputValue = getEmptyItemState(getState(), itemId).inputValue
446 | let suggestions = storage.autoSuggestSearch(getState().storage, {inputValue})
447 | // Update list of suggestions for this user input
448 | dispatch(setAutoSuggestSuggestions({inputValue, suggestions}))
449 | }
450 | }
451 |
452 | export let setAutoSuggestSuggestions = createAction()
453 | export let setEmptyItemState = createAction()
454 | export let updateEmptyItemSuggestions = createAction()
455 |
456 | export function connectItems({sourceItemId, targetItemId}) {
457 | return function(dispatch, getState) {
458 | let sourceItem = canvas.getItem(getState().canvas, sourceItemId)
459 | let targetItem = canvas.getItem(getState().canvas, targetItemId)
460 |
461 | // Create link in storage
462 | let linkId = dispatch(storage.findOrAddLink({
463 | source: sourceItem.docId,
464 | target: targetItem.docId,
465 | type: 'manual',
466 | }))
467 |
468 | // Display edge
469 | dispatch(canvas.showEdge({
470 | linkId,
471 | sourceItemId,
472 | targetItemId,
473 | }))
474 | }
475 | }
476 |
477 | export function handleReceivedDrop({itemId, droppedItemId}) {
478 | return function (dispatch, getState) {
479 | dispatch(connectItems({sourceItemId: itemId, targetItemId: droppedItemId}))
480 | }
481 | }
482 |
483 | export function handleResetCanvas() {
484 | return initCanvas({animate: true})
485 | }
486 |
487 | export function handleEscape() {
488 | return function (dispatch, getState) {
489 | let itemId = canvas.getItemIdForDocId(getState().canvas, 'emptyItem_alone')
490 | dispatch(canvas.focusItem({itemId}))
491 | }
492 | }
493 |
--------------------------------------------------------------------------------