├── .gitignore ├── .babelrc ├── screenshot.jpg ├── app ├── Dockerfile ├── assets │ ├── background.jpg │ ├── GrundschriftNormal.otf │ ├── demovideo.html │ └── style.css ├── index.html └── nginx-server.conf ├── src ├── storage │ ├── index.js │ ├── actions.js │ ├── reducer.js │ └── selectors.js ├── canvas │ ├── index.js │ ├── utils.js │ ├── selectors.js │ ├── components │ │ ├── AnimatedItemContainer.jsx │ │ ├── Edge.jsx │ │ ├── ItemContainer.jsx │ │ └── Canvas.jsx │ ├── actions.js │ └── reducer.js ├── reducer │ ├── index.js │ └── volatile.js ├── selectors.js ├── store.js ├── main.jsx ├── backuptools.js ├── sagas.js ├── utils.js ├── components │ ├── StemItem.jsx │ ├── Webpage.jsx │ ├── Note.jsx │ └── EmptyItem.jsx └── actions.js ├── Makefile ├── package.json └── Readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | app/assets/main.js 3 | *.min.js 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-1", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebMemex/webmemex-demo/HEAD/screenshot.jpg -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | 3 | COPY . /app 4 | COPY nginx-server.conf /etc/nginx/conf.d/default.conf 5 | -------------------------------------------------------------------------------- /app/assets/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebMemex/webmemex-demo/HEAD/app/assets/background.jpg -------------------------------------------------------------------------------- /app/assets/GrundschriftNormal.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebMemex/webmemex-demo/HEAD/app/assets/GrundschriftNormal.otf -------------------------------------------------------------------------------- /src/storage/index.js: -------------------------------------------------------------------------------- 1 | import reducer from './reducer' 2 | export { reducer } 3 | import * as actions from './actions' 4 | export { actions } 5 | import * as selectors from './selectors' 6 | export { selectors } 7 | 8 | export default { reducer, ...actions, ...selectors} 9 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Web memex 4 | 5 | 6 | 7 | 8 |
9 | Loading...? 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/canvas/index.js: -------------------------------------------------------------------------------- 1 | import Canvas from './components/Canvas' 2 | export { Canvas } 3 | import reducer from './reducer' 4 | export { reducer } 5 | import * as actions from './actions' 6 | export { actions } 7 | import * as selectors from './selectors' 8 | export { selectors } 9 | 10 | export default { Canvas, reducer, ...actions, ...selectors } 11 | -------------------------------------------------------------------------------- /src/reducer/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | 3 | import storage from '../storage' 4 | import canvas from '../canvas' 5 | import volatileReducer from './volatile' 6 | 7 | let rootReducer = combineReducers({ 8 | storage: storage.reducer, 9 | canvas: canvas.reducer, 10 | volatile: volatileReducer, 11 | }) 12 | 13 | export default rootReducer 14 | -------------------------------------------------------------------------------- /app/assets/demovideo.html: -------------------------------------------------------------------------------- 1 | 8 | 15 | -------------------------------------------------------------------------------- /src/selectors.js: -------------------------------------------------------------------------------- 1 | export function getEmptyItemState(state, itemId) { 2 | let itemState = state.volatile.emptyItems[itemId] 3 | return itemState 4 | } 5 | 6 | export function getAutoSuggestSuggestions(state, inputValue) { 7 | let autoSuggestion = state.volatile.autoSuggestions[inputValue] 8 | if (autoSuggestion) { 9 | return autoSuggestion.suggestions 10 | } 11 | return [] 12 | } 13 | -------------------------------------------------------------------------------- /app/nginx-server.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8086; 3 | 4 | location / { 5 | root /app; 6 | add_header Cache-Control max-age=2592000; 7 | } 8 | 9 | location ~ ^(/live/|/replay/|/record/|/replay-record/|/static/) { 10 | proxy_pass http://pywb-webrecorder:8080; 11 | proxy_set_header Host $http_host; 12 | add_header Cache-Control max-age=31556926; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/canvas/utils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | import { ensureUnusedId } from '../utils' 4 | 5 | // Helper function to find or generate the itemId for a new item. 6 | // Used by both the createItem action (thunk) and createItem (kind-of-)reducer. 7 | export function reuseOrGenerateItemId(state, {docId}) { 8 | // Try reuse an item that is flagged for removal (mainly to animate moving) 9 | let itemId = _.findKey(state.visibleItems, item => ( 10 | item.docId == docId && item.flaggedForRemoval 11 | )) 12 | // Else, pick an identifier to create a new item 13 | if (itemId === undefined) { 14 | let desiredItemId = 'view_'+docId 15 | itemId = ensureUnusedId(state.visibleItems, desiredItemId) 16 | } 17 | return itemId 18 | } 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE_NAME = webmemex-local 2 | CONTAINER_NAME = webmemex 3 | DOCKER_ARGS = -p 8086:8086 --link pywb-webrecorder --name ${CONTAINER_NAME} ${IMAGE_NAME} 4 | ENV = MEMEX_PROXY_URL_PREFIX=/replay-record/ 5 | 6 | .PHONY: run 7 | run: 8 | docker run --rm -it ${DOCKER_ARGS} 9 | 10 | .PHONY: rund 11 | rund: 12 | docker run --restart=on-failure -d ${DOCKER_ARGS} 13 | 14 | .PHONY: stopd 15 | stopd: 16 | docker rm -f ${CONTAINER_NAME} || true 17 | 18 | .PHONY: dev 19 | dev: stopd 20 | ${ENV} npm run watch | : | docker run --rm -t -v `pwd`/app:/app ${DOCKER_ARGS} 21 | 22 | .PHONY: build 23 | build: build-prod build-image 24 | 25 | .PHONY: build-image 26 | build-image: 27 | docker build -t ${IMAGE_NAME} app 28 | 29 | .PHONY: build-prod 30 | build-prod: 31 | ${ENV} npm run build-prod 32 | 33 | .PHONY: build-dev 34 | build-dev: 35 | ${ENV} npm run build-dev 36 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux' 2 | import thunk from 'redux-thunk' 3 | import PouchDB from 'pouchdb' 4 | import { persistentStore } from 'redux-pouchdb' 5 | import createSagaMiddleware from 'redux-saga' 6 | 7 | import rootReducer from './reducer' 8 | import sagas from './sagas' 9 | 10 | // Creates the redux store for application state 11 | // (both the persistent storage and the canvas state) 12 | export default function makeStore() { 13 | const db = new PouchDB('reduxstore') 14 | 15 | const sagaMiddleware = createSagaMiddleware() 16 | 17 | const enhancer = compose( 18 | persistentStore(db), 19 | applyMiddleware(sagaMiddleware, thunk), 20 | ) 21 | 22 | const store = createStore(rootReducer, undefined, enhancer) 23 | 24 | for (let saga in sagas) { 25 | sagaMiddleware.run(sagas[saga]) 26 | } 27 | 28 | return store 29 | } 30 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | 5 | import makeStore from './store' 6 | import { Canvas } from './canvas' 7 | import StemItem from './components/StemItem' 8 | import * as actions from './actions' 9 | 10 | const store = makeStore() 11 | 12 | ReactDOM.render( 13 | 14 | 15 | , 16 | document.getElementById('app-container') 17 | ) 18 | 19 | // Fallback to initialise the canvas content; redux-pouchdb should trigger this 20 | // after loading persisted data, but if that fails we do it manually here. 21 | window.setTimeout(()=>store.dispatch(actions.initCanvas()), 3000) 22 | 23 | // Listen for link clicks 24 | window.addEventListener('message', function (m) { 25 | let url = m.data.url 26 | console.log('Received URL: ' + url) 27 | if (url !== undefined) { 28 | store.dispatch(actions.navigateFromLink({url})) 29 | } 30 | }) 31 | 32 | // XXX Global variable passing to enable us to subscribe to the store in the initCanvas action. 33 | window.store=store 34 | 35 | // TESTING 36 | import storage from './storage' 37 | window.storage=storage 38 | import canvas from './canvas' 39 | window.canvas=canvas 40 | 41 | import './backuptools' 42 | -------------------------------------------------------------------------------- /src/canvas/selectors.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export function getItem(state, itemId) { 4 | let item = state.visibleItems[itemId] 5 | if (!item) 6 | throw new Error('No item with id ' + itemId + ' found in state.') 7 | return item 8 | } 9 | 10 | export function getItemIdForDocId(state, docId) { 11 | let itemId = _.findKey(state.visibleItems, item => (item.docId === docId)) 12 | if (!itemId) 13 | throw new Error('No item with docId ' + docId + ' found in state.') 14 | return itemId 15 | } 16 | 17 | export function getCenteredItem(state) { 18 | let currentItem = state.centeredItem 19 | if (!currentItem) { 20 | return undefined 21 | } 22 | else { 23 | return getItem(state, currentItem) 24 | } 25 | } 26 | 27 | export function getConnectedItemIds(state, itemId) { 28 | return _(state.edges) 29 | .pickBy(edge => (edge.sourceItemId === itemId || edge.targetItemId === itemId)) 30 | .map(edge => edge.sourceItemId === itemId ? edge.targetItemId : edge.sourceItemId) 31 | .value() 32 | } 33 | 34 | export function getVisibleLinksOfItem(state, itemId) { 35 | return (_(state.edges) 36 | .pickBy(edge => (edge.sourceItemId === itemId || edge.targetItemId === itemId)) 37 | .map('linkId') 38 | .value() 39 | ) 40 | } 41 | 42 | export function isFocussed(state, itemId) { 43 | return (state.focussedItem === itemId) 44 | } 45 | -------------------------------------------------------------------------------- /src/backuptools.js: -------------------------------------------------------------------------------- 1 | // Functions for manual storage backup and restoration 2 | function dumpStorage({asFile=true}={}) { 3 | let dump = JSON.stringify(store.getState().storage, null, 2) 4 | if (asFile) { 5 | const dataUrl = "data:text/json;charset=utf-8," + window.escape(dump) 6 | var link = document.createElement("a") 7 | link.download = 'webmemex-backup.json' 8 | link.href = dataUrl 9 | document.body.appendChild(link) 10 | link.click() 11 | document.body.removeChild(link) 12 | } 13 | else { 14 | return dump 15 | } 16 | } 17 | function importFromDump({storageDump, deleteCurrent=false}={}) { 18 | if (storageDump === undefined) 19 | storageDump = prompt('Paste your JSON storage dump here (obtained from dumpStorage())') 20 | if (typeof storageDump === 'string') { 21 | if (storageDump === '') { 22 | storageDump = '{"docs": {}, "links": {}}' 23 | } 24 | storageDump = JSON.parse(storageDump) 25 | } 26 | 27 | let docCount = Object.keys(storageDump.docs).length 28 | let message = deleteCurrent 29 | ? 'Delete all, then import ' + docCount + ' docs?' 30 | : 'Load ' + docCount + ' docs?' 31 | if (confirm(message)) { 32 | store.dispatch(storage.importFromDump({storageDump, deleteCurrent})) 33 | } 34 | } 35 | function deleteAllDocs() { 36 | const cleanState = {docs: {}, links: {}} 37 | importFromDump({storageDump: cleanState, deleteCurrent: true}) 38 | } 39 | window.dumpStorage = dumpStorage 40 | window.importFromDump = importFromDump 41 | window.deleteAllDocs = deleteAllDocs 42 | -------------------------------------------------------------------------------- /src/canvas/components/AnimatedItemContainer.jsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import React from 'react' 3 | import { connect } from 'react-redux' 4 | import { Motion, spring } from 'react-motion'; 5 | 6 | import ItemContainer from './ItemContainer' 7 | import { getItem } from '../selectors' 8 | import { hasProps } from '../../utils' 9 | 10 | let AnimatedItemContainer = React.createClass({ 11 | 12 | shouldComponentUpdate: hasProps('position'), 13 | 14 | render() { 15 | let position = this.props.position 16 | let ownProps = this.props.ownProps 17 | let interpolationParams = (this.props.inTransition) ? 18 | _.mapValues(position, value => spring(value, {stiffness:170})) 19 | : position // pass the position directly, disables animation 20 | return ( 21 | 24 | { 25 | interpolated => 26 | } 27 | 28 | ) 29 | } 30 | 31 | }) 32 | 33 | 34 | function mapStateToProps(state, ownProps) { 35 | state = state.canvas 36 | let item 37 | try { 38 | item = getItem(state, ownProps.itemId) 39 | } 40 | catch (err) { 41 | return {} // we must be phasing out, prevent updating. 42 | } 43 | let {x, y, width, height, inTransition} = item 44 | let position = {x, y, width, height} 45 | return { 46 | ownProps, 47 | position, 48 | inTransition, 49 | } 50 | } 51 | 52 | export default connect(mapStateToProps)(AnimatedItemContainer) 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webmemex", 3 | "version": "0.1.0", 4 | "description": "A read/write web browser", 5 | "main": "app/index.html", 6 | "scripts": { 7 | "build-prod": "NODE_ENV=production browserify -g uglifyify --extension 'jsx' src/main.jsx | uglifyjs -m -c warnings=false > app/assets/main.js", 8 | "build-dev": " browserify --extension 'jsx' src/main.jsx -o app/assets/main.js", 9 | "watch": " watchify -v --extension 'jsx' src/main.jsx -o app/assets/main.js", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "Gerben", 13 | "license": "CC0-1.0", 14 | "dependencies": { 15 | "babel-polyfill": "^6.9.1", 16 | "classnames": "^2.2.3", 17 | "lodash": "^4.11.1", 18 | "pouchdb": "^6.0.7", 19 | "react": "^0.14.8", 20 | "react-autosuggest": "^3.8.0", 21 | "react-contenteditable": "git://github.com/Treora/react-contenteditable#userIsKing", 22 | "react-dom": "^0.14.8", 23 | "react-motion": "^0.4.3", 24 | "react-redux": "^4.4.1", 25 | "redux": "^3.3.1", 26 | "redux-act": "^0.4.0", 27 | "redux-pouchdb": "^0.1.1", 28 | "redux-query-sync": "^0.1.6", 29 | "redux-saga": "^0.10.4", 30 | "redux-thunk": "^2.1.0" 31 | }, 32 | "devDependencies": { 33 | "babel": "^6.5.2", 34 | "babel-preset-es2015": "^6.6.0", 35 | "babel-preset-react": "^6.5.0", 36 | "babel-preset-stage-1": "^6.5.0", 37 | "babelify": "^7.2.0", 38 | "envify": "^3.4.1", 39 | "uglify-js": "^2.7.3", 40 | "uglifyify": "^3.0.3", 41 | "watchify": "^3.7.0" 42 | }, 43 | "browserify": { 44 | "transform": [ 45 | "babelify", 46 | "envify" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/reducer/volatile.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from 'redux-act'; 2 | 3 | import * as actions from '../actions' 4 | 5 | let defaultState = { 6 | emptyItems: { 7 | // [itemId]: {inputValue, inputValueForSuggestions} 8 | }, 9 | autoSuggestions: { 10 | // [inputValue]: {suggestions: [...]}, 11 | }, 12 | } 13 | 14 | // Notes on our auto-suggest implementation: 15 | // Browsing through suggestions updates inputValue, so we remember the last 16 | // 'manually entered' input value in inputValueForSuggestions. We then look up 17 | // this value in state.autoSuggestions, which acts as a cache for search results. 18 | 19 | function setEmptyItemState(state, {itemId, props}) { 20 | let item = {...state.emptyItems[itemId], ...props} 21 | let emptyItems = {...state.emptyItems, [itemId]: item} 22 | return {...state, emptyItems} 23 | } 24 | 25 | function updateEmptyItemSuggestions(state, {itemId}) { 26 | let item = state.emptyItems[itemId] 27 | 28 | item.inputValueForSuggestions = item.inputValue 29 | 30 | let emptyItems = {...state.emptyItems, [itemId]: item} 31 | return {...state, emptyItems} 32 | } 33 | 34 | function setAutoSuggestSuggestions(state, {itemId, inputValue, suggestions}) { 35 | let autoSuggestions = {...state.autoSuggestions, [inputValue]: {suggestions}} 36 | return {...state, autoSuggestions} 37 | } 38 | 39 | let reducer = createReducer( 40 | { 41 | [actions.setAutoSuggestSuggestions]: setAutoSuggestSuggestions, 42 | [actions.setEmptyItemState]: setEmptyItemState, 43 | [actions.updateEmptyItemSuggestions]: updateEmptyItemSuggestions, 44 | }, 45 | defaultState 46 | ) 47 | 48 | export default reducer 49 | -------------------------------------------------------------------------------- /src/canvas/components/Edge.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { bindActionCreators } from 'redux' 4 | 5 | import * as actions from '../actions' 6 | import { getItem } from '../selectors' 7 | import { hasProps } from '../../utils' 8 | 9 | let Edge = React.createClass({ 10 | 11 | shouldComponentUpdate: hasProps('sourceItem', 'targetItem'), 12 | 13 | render() { 14 | let { sourceItem, targetItem, signalEdgeTapped } = this.props 15 | 16 | let getItemCenterCoords = item => ({ 17 | x: item.x + item.width/2, 18 | y: item.y + item.height/2, 19 | }) 20 | let sourceCenter = getItemCenterCoords(sourceItem) 21 | let targetCenter = getItemCenterCoords(targetItem) 22 | let edgeCoords = 'M ' + sourceCenter.x + ' ' + sourceCenter.y 23 | + ' L ' + targetCenter.x + ' ' + targetCenter.y 24 | return ( 25 | signalEdgeTapped(event)} 29 | onTouchStart={event => signalEdgeTapped(event)} 30 | /> 31 | ) 32 | } 33 | }) 34 | 35 | 36 | function mapStateToProps(state, {sourceItemId, targetItemId}) { 37 | state = state.canvas 38 | return { 39 | sourceItem: getItem(state, sourceItemId), 40 | targetItem: getItem(state, targetItemId), 41 | } 42 | } 43 | 44 | function mapDispatchToProps(dispatch, {sourceItemId, targetItemId}) { 45 | let signalEdgeTapped = event => actions.signalEdgeTapped({ 46 | event, 47 | sourceItemId, targetItemId, 48 | }) 49 | 50 | return bindActionCreators({ 51 | signalEdgeTapped, 52 | }, dispatch) 53 | } 54 | 55 | export default connect(mapStateToProps, mapDispatchToProps)(Edge) 56 | -------------------------------------------------------------------------------- /src/sagas.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' // required for using generators 2 | import { takeEvery } from 'redux-saga' 3 | import { put } from 'redux-saga/effects' 4 | import { INIT as reduxPouchInit} from 'redux-pouchdb' 5 | 6 | import canvas from './canvas' 7 | import * as actions from './actions' 8 | 9 | 10 | // Hook our local action handlers to actions defined in modules 11 | // (because just like reducers can handle actions, other actions should too) 12 | const hookedActions = { 13 | [canvas.signalItemTapped.getType()]: actions.handleTapItem, 14 | [canvas.signalItemDraggedOut.getType()]: actions.handleDraggedOut, 15 | [canvas.signalDropOnCanvas.getType()]: actions.handleDropOnCanvas, 16 | [canvas.signalCanvasTapped.getType()]: actions.handleTapCanvas, 17 | [canvas.signalEdgeTapped.getType()]: actions.handleTapEdge, 18 | [canvas.signalReceivedDrop.getType()]: actions.handleReceivedDrop, 19 | [canvas.signalResetCanvas.getType()]: actions.handleResetCanvas, 20 | [canvas.signalEscape.getType()]: actions.handleEscape, 21 | 22 | // Initialise the canvas when stored state has been loaded from PouchDB 23 | [reduxPouchInit]: actions.initCanvas, 24 | } 25 | 26 | let sagas = [] 27 | 28 | // Create a saga for each pair of action+handler 29 | for (let sourceAction in hookedActions) { 30 | let actionHandler = hookedActions[sourceAction] 31 | sagas.push(bindHandler(sourceAction, actionHandler)) 32 | } 33 | 34 | // A saga that simply listens to one type of action and triggers its assigned handler. 35 | function bindHandler(sourceAction, actionHandler) { 36 | return function* actionListener() { 37 | yield* takeEvery(sourceAction, function* (actionObject) { 38 | try { 39 | yield put(actionHandler(actionObject.payload)) 40 | } catch (err) { 41 | console.error(`Error in saga handling ${sourceAction}: ${err}`) 42 | } 43 | }) 44 | } 45 | } 46 | 47 | export default sagas 48 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | /* hasProps(requiredProps)(givenProps) 4 | * Checks if required properties are defined in the given props object. 5 | * Used by some components' shouldComponentUpdate methods, to prevent trying to 6 | * update a component that has been removed from the state (and will be removed 7 | * from the DOM as soon as the parent component also updates) 8 | */ 9 | export function hasProps(...requiredProps) { 10 | return function _hasProps(givenProps) { 11 | return requiredProps.every(propName=>givenProps[propName]) 12 | } 13 | } 14 | 15 | export function asUrl(input) { 16 | // A URI, with or without the scheme ('xxx://') 17 | const matcher = /^(?:(?:\w+:)?\/\/)?([^\s\.]+\.\S{2}|localhost[\:?\d]*)\S*$/ 18 | if (matcher.test(input)) { 19 | if (!(input.startsWith('http://') 20 | || input.startsWith('https://') 21 | || input.startsWith('file://'))) { // TODO let regex test scheme. 22 | input = 'http://'+input 23 | } 24 | return input 25 | } 26 | else { 27 | return undefined 28 | } 29 | } 30 | 31 | export function textToHtml(text) { 32 | return text.replace(/&/g, '&') 33 | .replace(//g, '>') 35 | .replace(/\n/g, '
') 36 | } 37 | 38 | // Wrap redux-act's createAction to reduce code repetition 39 | // (perhaps redux-act is still not opinionated enough) 40 | import { createAction } from 'redux-act' 41 | export function createActionWithMetaArgs(metaArgs) { 42 | // Resolve all (factory/getter) functions among the argument values 43 | function getMetaArgs() { 44 | return _.mapValues(metaArgs, 45 | value => (_.isFunction(value) ? value() : value) 46 | ) 47 | } 48 | return createAction( 49 | undefined, // we could have given the action a name here 50 | payload => payload, // we never modify action payloads 51 | payload => getMetaArgs(), // we pass 'meta' arguments to the reducer 52 | ) 53 | } 54 | 55 | // Append a number to the desiredId when it is already in use 56 | export function ensureUnusedId(collection, desiredId) { 57 | let id = desiredId 58 | let i = 1 59 | while (id in collection) { 60 | id = desiredId + (i++).toString() 61 | } 62 | return id 63 | } 64 | -------------------------------------------------------------------------------- /src/components/StemItem.jsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import React from 'react' 3 | import { bindActionCreators } from 'redux' 4 | import { connect } from 'react-redux' 5 | 6 | import Note from './Note' 7 | import Webpage from './Webpage' 8 | import EmptyItem from './EmptyItem' 9 | import { hasProps } from '../utils' 10 | import canvas from '../canvas' 11 | import storage from '../storage' 12 | import { drawStar } from '../actions' 13 | 14 | let StemItem = React.createClass({ 15 | 16 | shouldComponentUpdate: hasProps('canvasItem'), 17 | 18 | render() { 19 | // Return a different component, depending on the document's type 20 | if (this.props.docId.startsWith('emptyItem')) { 21 | return 22 | } 23 | else if (this.props.text !== undefined) 24 | return 25 | else if (this.props.url !== undefined) 26 | return 27 | else 28 | return Empty item? docId={this.props.docId}, canvasItemId={this.props.canvasItemId} 29 | } 30 | 31 | }) 32 | 33 | 34 | function mapStateToProps(state, {docId, canvasItemId}) { 35 | let doc = {} 36 | // Get the document from storage unless docId is not a real doc. 37 | if (!docId.startsWith('emptyItem') && docId !== undefined) { 38 | try { 39 | doc = storage.getDoc(state.storage, docId) 40 | } catch (err) { 41 | console.error(err) 42 | return {} 43 | } 44 | } 45 | 46 | let canvasItem 47 | try { 48 | canvasItem = canvas.getItem(state.canvas, canvasItemId) 49 | } 50 | catch (err) { 51 | return {} // we must be phasing out, prevent updating. 52 | } 53 | 54 | let focussed = canvas.isFocussed(state.canvas, canvasItemId) 55 | 56 | return { 57 | ...doc, 58 | canvasItem, 59 | focussed, 60 | } 61 | } 62 | 63 | function mapDispatchToProps(dispatch, {canvasItemId}) { 64 | return { 65 | blur: () => dispatch(canvas.unfocus({itemId: canvasItemId})), 66 | focus: () => dispatch(canvas.focusItem({itemId: canvasItemId})), 67 | ...bindActionCreators({ 68 | expandItem: canvas.expandItem, 69 | drawStar, 70 | }, dispatch) 71 | } 72 | } 73 | 74 | export default connect(mapStateToProps, mapDispatchToProps)(StemItem) 75 | -------------------------------------------------------------------------------- /src/storage/actions.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { createAction } from 'redux-act' 3 | 4 | import { createActionWithMetaArgs, ensureUnusedId } from '../utils' 5 | import { getDocWithUrl, getDocWithText } from './selectors' 6 | 7 | export function findOrAddUrl({url}) { 8 | return function (dispatch, getState) { 9 | // Search if we have it already 10 | let docId = getDocWithUrl(getState().storage, url) // XXX non-modular: We'd want to get state.storage from getState(). 11 | // If not, create a new document 12 | if (!docId) { 13 | docId = ensureUnusedId(getState().storage.docs, generateIdentifier()) 14 | dispatch(addUrl({docId, url})) 15 | } 16 | return docId 17 | } 18 | } 19 | 20 | export function findOrAddNote({text}) { 21 | return function (dispatch, getState) { 22 | // Search if we have it already 23 | let docId = getDocWithText(getState().storage, text) // XXX non-modular: We'd want to get state.storage from getState(). 24 | // If not, create a new document 25 | if (!docId) { 26 | docId = ensureUnusedId(getState().storage.docs, generateIdentifier()) 27 | dispatch(addNote({docId, text})) 28 | } 29 | return docId 30 | } 31 | } 32 | 33 | export function findOrAddLink({source, target, type}) { 34 | return function (dispatch, getState) { 35 | // Search if we have it already 36 | let linkId = _.findKey(getState().storage.links, // non-modular.. 37 | link => (link.source===source && link.target===target) 38 | ) 39 | // If not, create a new link 40 | if (!linkId) { 41 | linkId = ensureUnusedId(getState().storage.links, generateIdentifier()) 42 | dispatch(addLink({linkId, source, target, type})) 43 | 44 | // Remove any link in the other direction, to keep things simple. 45 | let revLinkId = _.findKey(getState().storage.links, // non-modular.. 46 | link => (link.source===target && link.target===source) 47 | ) 48 | if (revLinkId) { 49 | dispatch(deleteLink({linkId: revLinkId})) 50 | } 51 | } 52 | return linkId 53 | } 54 | } 55 | 56 | export let addUrl = createAction() 57 | export let addNote = createAction() 58 | export let updateNoteText = createAction() 59 | export let deleteDoc = createAction() 60 | export let addLink = createAction() 61 | export let deleteLink = createAction() 62 | export let importFromDump = createAction() 63 | 64 | 65 | function generateIdentifier() { 66 | return 'id_' + new Date().getTime().toString() 67 | } 68 | -------------------------------------------------------------------------------- /src/canvas/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-act' 2 | import { createActionWithMetaArgs } from '../utils' 3 | 4 | import { reuseOrGenerateItemId } from './utils' 5 | 6 | // Actions processed by the canvas reducer 7 | 8 | export let createItem = function ({docId, props}) { 9 | // We use a thunk here rather than putting this logic in the reducer, solely 10 | // to be able to return the chosen itemId to the caller. TODO fix Redux..? 11 | return function(dispatch, getState) { 12 | let state = getState().canvas // XXX non-modular: We'd want to get state.canvas from getState(). 13 | let itemId = reuseOrGenerateItemId(state, {docId}) 14 | dispatch(createItemWithId({itemId, docId, props})) 15 | return itemId 16 | } 17 | } 18 | 19 | export let createItemWithId = createAction() 20 | export let changeDoc = createAction() 21 | export let centerItem = createActionWithMetaArgs({ 22 | currentView: getCurrentView, 23 | }) 24 | export let centerDocWithFriends = createActionWithMetaArgs({ 25 | currentView: getCurrentView, 26 | }) 27 | export let removeAllItems = createAction() 28 | export let showItemFriends = createAction() 29 | export let hideEdge = createAction() 30 | export let showEdge = createAction() 31 | export let hideItem = createAction() 32 | export let updateWindowSize = createActionWithMetaArgs({ 33 | currentView: getCurrentView, 34 | }) 35 | export let relocateItem = createActionWithMetaArgs({ 36 | currentView: getCurrentView, 37 | }) 38 | export let resizeItem = createAction() 39 | export let setItemRatio = createAction() 40 | export let scaleItem = createAction() 41 | export let expandItem = createActionWithMetaArgs({ 42 | currentView: getCurrentView, 43 | }) 44 | export let unexpand = createAction() 45 | export let setItemDragged = createAction() 46 | export let handleDragEnter = createAction() 47 | export let handleDragLeave = createAction() 48 | export let focusItem = createAction() 49 | export let unfocusItem = createAction() 50 | export let unfocus = createAction() 51 | export let setProps = createAction() 52 | 53 | // Actions not listened to by the canvas module itself 54 | 55 | export let signalItemTapped = createAction() 56 | export let signalItemDraggedOut = createAction() 57 | export let signalDropOnCanvas = createAction() 58 | export let signalCanvasTapped = createAction() 59 | export let signalEdgeTapped = createAction() 60 | export let signalReceivedDrop = createAction() 61 | export let signalResetCanvas = createAction() 62 | export let signalEscape = createAction() 63 | 64 | // Utils 65 | 66 | function getCurrentView() { 67 | return { 68 | scrollX: window.scrollX, 69 | scrollY: window.scrollY, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/storage/reducer.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { persistentReducer } from 'redux-pouchdb'; 3 | import { createReducer } from 'redux-act'; 4 | 5 | import * as actions from './actions' 6 | import { getDoc } from './selectors' 7 | import { ensureUnusedId } from '../utils' 8 | 9 | let defaultState = { 10 | docs: { 11 | // [docId]: {url: '....'}, 12 | // [docId]: {text: '....'}, 13 | }, 14 | links: { 15 | // [linkId]: {source: [sourceDocId], target: [targetDocId]} 16 | }, 17 | } 18 | 19 | function addUrl(state, {docId, url}) { 20 | let newDoc = {url: url} 21 | return {...state, docs: {...state.docs, [docId]: newDoc}} 22 | } 23 | 24 | function addNote(state, {docId, text}) { 25 | let newDoc = {text} 26 | return {...state, docs: {...state.docs, [docId]: newDoc}} 27 | } 28 | 29 | function updateNoteText(state, {docId, text}) { 30 | let doc = getDoc(state, docId) 31 | let newDoc = {...doc, text} 32 | return {...state, docs: {...state.docs, [docId]: newDoc}} 33 | } 34 | 35 | function deleteDoc(state, {docId}) { 36 | // Remove doc 37 | let docs = _.omit(state.docs, docId) 38 | // Also remove links to and from doc 39 | let links = _.omitBy(state.links, ({source, target}) => (source===docId || target===docId)) 40 | return {...state, docs, links} 41 | } 42 | 43 | function addLink(state, {linkId, source, target, type}) { 44 | // Refuse creating self-links 45 | if (source === target) 46 | return state 47 | let newLink = {source, target, type} 48 | return {...state, links: {...state.links, [linkId]: newLink}} 49 | } 50 | 51 | // Delete a link. Pass either a linkId, or two docIds. 52 | function deleteLink(state, {linkId, doc1, doc2}) { 53 | let links 54 | if (linkId===undefined) { 55 | links = _.omitBy(state.links, ({source, target}) => ( 56 | (source===doc1 && target===doc2) || (source===doc2 && target===doc1) 57 | )) 58 | } 59 | else { 60 | links = _.omit(state.links, linkId) 61 | } 62 | return {...state, links} 63 | } 64 | 65 | function importFromDump(state, {storageDump, deleteCurrent=false}) { 66 | if (deleteCurrent===true) { 67 | return storageDump 68 | } 69 | else { 70 | let stateCopy = _.cloneDeep(state) // Because _.merge mutates it. 71 | return _.merge(stateCopy, storageDump) 72 | } 73 | } 74 | 75 | let reducer = createReducer( 76 | { 77 | [actions.addUrl]: addUrl, 78 | [actions.addNote]: addNote, 79 | [actions.updateNoteText]: updateNoteText, 80 | [actions.deleteDoc]: deleteDoc, 81 | [actions.addLink]: addLink, 82 | [actions.deleteLink]: deleteLink, 83 | [actions.importFromDump]: importFromDump, 84 | }, 85 | defaultState 86 | ) 87 | 88 | // wrap it to set a function name for redux-pouchdb 89 | function storageReducer(...args) { 90 | return reducer(...args) 91 | } 92 | 93 | export default persistentReducer(storageReducer) 94 | -------------------------------------------------------------------------------- /src/canvas/components/ItemContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classNames from 'classnames' 3 | import { bindActionCreators } from 'redux' 4 | import { connect } from 'react-redux' 5 | 6 | import * as actions from '../actions' 7 | import { getItem } from '../selectors' 8 | import { hasProps } from '../../utils' 9 | 10 | let ItemContainer = React.createClass({ 11 | 12 | shouldComponentUpdate: hasProps('itemId'), 13 | 14 | render() { 15 | const { x, y, width, height, canvasSize: {width: canvasWidth} } = this.props 16 | let style = { 17 | left: x, 18 | top: y, 19 | width: width, 20 | height: height, 21 | } 22 | 23 | let className = classNames( 24 | 'item-container', 25 | this.props.classes, 26 | {'expanded': this.props.expanded} 27 | ) 28 | 29 | let handleTap = event => { 30 | this.props.tap(event) 31 | event.stopPropagation() 32 | } 33 | 34 | const toggleExpand = event => { 35 | event.stopPropagation() 36 | if (!this.props.expanded) { 37 | this.props.expandItem() 38 | } 39 | else { 40 | this.props.unexpand() 41 | } 42 | } 43 | 44 | return ( 45 |
52 | 58 | {this.props.centered && ( 59 | 66 | )} 67 |
68 | ) 69 | }, 70 | 71 | }) 72 | 73 | 74 | function mapStateToProps(state, ownProps) { 75 | state = state.canvas 76 | let itemId = ownProps.itemId 77 | 78 | let item 79 | try { 80 | item = getItem(state, itemId) 81 | } 82 | catch (err) { 83 | return {} // we must be phasing out, prevent updating. 84 | } 85 | 86 | return { 87 | ...item, 88 | // For animation, let (interpolated) props override state values 89 | ...ownProps, 90 | canvasSize: state.canvasSize, 91 | } 92 | } 93 | 94 | function mapDispatchToProps(dispatch, {itemId}) { 95 | return bindActionCreators({ 96 | expandItem: () => actions.expandItem({itemId, animate: true}), 97 | unexpand: () => actions.unexpand({animate: true}), 98 | tap: (event) => actions.signalItemTapped({itemId, event}), 99 | }, dispatch) 100 | } 101 | 102 | export default connect(mapStateToProps, mapDispatchToProps)(ItemContainer) 103 | -------------------------------------------------------------------------------- /src/components/Webpage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | let Webpage = React.createClass({ 4 | getInitialState() { 5 | return {hasIFrame: false} 6 | }, 7 | 8 | calculateStyle(props) { 9 | let canvasItemHeight = props.height 10 | let canvasItemWidth = props.width 11 | let minFramePageHeight = Math.max(canvasItemHeight, 600) 12 | let minFramePageWidth = Math.max(canvasItemWidth, 800) 13 | let scale = Math.min(canvasItemHeight / minFramePageHeight, 14 | canvasItemWidth / minFramePageWidth) 15 | 16 | let wrapperStyle = { 17 | height: canvasItemHeight, 18 | width: canvasItemWidth, 19 | } 20 | let scalingStyle = { 21 | height: canvasItemHeight / scale, 22 | width: canvasItemWidth / scale, 23 | transform: 'scale('+scale+')', 24 | } 25 | 26 | const showIFrame = props.canvasItem.centered || scale > 0.1 27 | 28 | return {scalingStyle, wrapperStyle, showIFrame} 29 | }, 30 | 31 | componentWillReceiveProps(nextProps) { 32 | let {showIFrame} = this.calculateStyle(nextProps) 33 | // If the iframe has been shown once, remember to keep it there. 34 | if (showIFrame) { 35 | this.setState({hasIFrame: true}) 36 | } 37 | }, 38 | 39 | componentWillMount() { 40 | this.componentWillReceiveProps(this.props) 41 | }, 42 | 43 | render() { 44 | let {scalingStyle, wrapperStyle, showIFrame} = this.calculateStyle(this.props) 45 | let expanded = this.props.canvasItem.expanded 46 | 47 | // If the page is tiny and did not have its iframe loaded already, don't bother loading one. Just show the URL. 48 | if (!showIFrame && !this.state.hasIFrame) { 49 | return ( 50 |
51 | {this.props.url.replace(/^https?:\/\//, '').replace(/^www./, '')} 52 |
53 | ) 54 | } 55 | 56 | let iframeSrcPrefix = process.env.MEMEX_PROXY_URL_PREFIX || '' 57 | // Hard-code exception for included youtube video to reduce proxy load. 58 | if (this.props.url.startsWith('https://www.youtube.com/embed')) { 59 | iframeSrcPrefix = '' 60 | } 61 | 62 | return ( 63 |
64 |
65 | 74 | { !expanded ?
: null} 75 |
76 |
77 | ) 78 | } 79 | }) 80 | 81 | export default Webpage 82 | -------------------------------------------------------------------------------- /src/components/Note.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { bindActionCreators } from 'redux' 3 | import { connect } from 'react-redux' 4 | import ContentEditable from 'react-contenteditable' 5 | 6 | import storage from '../storage' 7 | import { asUrl, textToHtml } from '../utils' 8 | 9 | let Note = React.createClass({ 10 | 11 | render() { 12 | // Only enable editing of the centered item (for now) 13 | this.editingEnabled = this.props.canvasItem.centered 14 | let html = this.props.text 15 | return ( 16 | this.props.handleChange(event.target.value)} 21 | className='note' 22 | userIsKing 23 | onBlur={event => this.props.blur()} 24 | onFocus={event => this.props.focus()} 25 | /> 26 | ) 27 | }, 28 | 29 | componentDidMount() { 30 | this.enableDrop() 31 | this.updateBrowserFocus() 32 | }, 33 | componentDidUpdate(oldProps) { 34 | this.updateBrowserFocus() 35 | }, 36 | 37 | updateBrowserFocus() { 38 | // Make browser state reflect application state. 39 | let el = this.refs['content'].htmlEl 40 | if (this.props.focussed && document.activeElement !== el) { 41 | el.focus() 42 | } 43 | if (!this.props.focussed && document.activeElement === el) { 44 | el.blur() 45 | } 46 | }, 47 | 48 | enableDrop() { 49 | const el = this.refs['content'].htmlEl 50 | const dragLeave = () => {el.style.backgroundColor = ''} 51 | el.ondragleave = dragLeave 52 | el.ondragenter = () => {el.style.backgroundColor = 'yellow'} 53 | el.ondragover = event => event.preventDefault() 54 | el.ondrop = event => { 55 | event.stopPropagation() 56 | event.preventDefault() 57 | dragLeave() 58 | let html = event.dataTransfer.getData('text/html') 59 | let text = event.dataTransfer.getData('text') 60 | let url = event.dataTransfer.getData('URL') || asUrl(text) 61 | 62 | let addition = '' 63 | if (url) { 64 | // Quick fix for removing proxy prefix from links and images dragged from our own iframes 65 | url = url.replace(new RegExp(`^(https?:)?\/\/${window.location.host}\/[a-z-]\/(im_\/)?`), '') 66 | addition = `${url}` 67 | } 68 | else if (html) { 69 | // FIXME problems with character encoding? At least when dropping html from Firefox into Chromium. 70 | addition = html 71 | } 72 | else if (text) { 73 | let html = textToHtml(text) 74 | addition = html 75 | } 76 | // We always append. 77 | const newText = this.props.text + addition 78 | this.props.handleChange(newText) 79 | } 80 | }, 81 | 82 | }) 83 | 84 | 85 | function mapStateToProps(state) { 86 | return { 87 | } 88 | } 89 | 90 | function mapDispatchToProps(dispatch, {docId}) { 91 | return bindActionCreators({ 92 | handleChange: text => storage.updateNoteText({docId, text}) 93 | }, dispatch) 94 | } 95 | 96 | export default connect(mapStateToProps, mapDispatchToProps)(Note) 97 | -------------------------------------------------------------------------------- /src/storage/selectors.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export function getDoc(state, docId) { 4 | let doc = state.docs[docId] 5 | if (doc === undefined) 6 | throw new Error('no doc with id ' + docId + ' in docs.') 7 | return doc 8 | } 9 | 10 | export function hasFriends(state, docId) { 11 | // Return true if doc has any links to/from other docs 12 | if (_.find(state.links, link => 13 | (link.source===docId || link.target===docId) 14 | && !(link.source==link.target) // self-links are not really friends.. 15 | )) 16 | return true 17 | else 18 | return false 19 | } 20 | 21 | export function getFriends(state, docId) { 22 | // Return all docs linking to, and all items linked from the given doc 23 | let allLinks = state.links 24 | let targetDocIds = _(allLinks) 25 | .pickBy(link => (link.source == docId)) 26 | .map(link => link.target) 27 | .value() 28 | let sourceDocIds = _(allLinks) 29 | .pickBy(link => (link.target == docId)) 30 | .map(link => link.source) 31 | .value() 32 | return {targetDocIds, sourceDocIds} 33 | } 34 | 35 | export function getDocWithUrl(state, url) { 36 | let docId = _.findKey(state.docs, doc => (doc.url===url)) 37 | return docId 38 | } 39 | 40 | export function getDocWithText(state, text) { 41 | let docId = _.findKey(state.docs, doc => (doc.text && doc.text.toLowerCase()===text.toLowerCase())) 42 | return docId 43 | } 44 | 45 | export function autoSuggestSearch(state, {inputValue, maxSuggestions=5}) { 46 | let lowText = inputValue.toLowerCase() 47 | let words = lowText.split(' ') 48 | let stripUrl = url => url.replace('http://', '').replace('https://','') 49 | let strippedUrl = stripUrl(lowText) 50 | 51 | let urlStartsWith = url => ( 52 | stripUrl(url).startsWith(strippedUrl) 53 | ) 54 | let urlContains = url => ( 55 | url.indexOf(lowText) > -1 56 | ) 57 | let urlContainsAllWords = url => ( 58 | words.every(word => url.toLowerCase().indexOf(word) > -1) 59 | ) 60 | let caseSensitiveStartsWith = docText => ( 61 | docText.startsWith(inputValue) 62 | ) 63 | let caseInsensitiveStartsWith = docText => ( 64 | docText.toLowerCase().startsWith(lowText) 65 | ) 66 | let containsWholeText = docText => ( 67 | docText.toLowerCase().indexOf(lowText) > -1 68 | ) 69 | let constainsAllWords = docText => ( 70 | words.every(word => docText.toLowerCase().indexOf(word) > -1) 71 | ) 72 | 73 | let urlMatchers = [ 74 | urlStartsWith, 75 | urlContains, 76 | urlContainsAllWords, 77 | ] 78 | let textMatchers = [ 79 | caseSensitiveStartsWith, 80 | caseInsensitiveStartsWith, 81 | containsWholeText, 82 | constainsAllWords, 83 | ] 84 | let suggestions = [] 85 | 86 | for (let i=0; idoc.text) 89 | .pickBy(doc=>textMatchers[i](doc.text)) 90 | .map((doc, docId) => ({docId, type: 'note', inputValueCompletion: doc.text})) 91 | .value() 92 | suggestions = _.uniqBy(suggestions.concat(matches), s=>s.docId) 93 | } 94 | for (let i=0; idoc.url) 97 | .pickBy(doc=>urlMatchers[i](doc.url)) 98 | .map((doc, docId) => ({docId, type: 'url', inputValueCompletion: doc.url})) 99 | .value() 100 | suggestions = _.uniqBy(suggestions.concat(matches), s=>s.docId) 101 | } 102 | suggestions.splice(maxSuggestions) 103 | return suggestions 104 | } 105 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # WebMemex 2 | 3 | A web browser that lets you save and organise the pages you visit. To create your personal web of pages and notes, connected by your assocations. 4 | 5 | This experiment was designed to run as a web app, so effectively you run a browser inside your browser (so you can browse while you browse!). 6 | 7 | This project is kept alive as a demo but is not developed further in this form. New forms are being worked on, see [webmemex.org](http://webmemex.org) for the current status. 8 | 9 | ![(screenshot)](screenshot.jpg) 10 | 11 | ## Idea 12 | 13 | Described [here](https://web.archive.org/web/20160303135717/http://read-write-web.org/). 14 | 15 | ## Try out 16 | 17 | 1. Open [demo.webmemex.org](http://demo.webmemex.org) in a modern browser. 18 | 2. Tell us what you think! [:email:](mailto:gerben@webmemex.org) 19 | 20 | ## Build 21 | 22 | Building and running this demo is currently a bit of a hassle. The setup consists of two containers: this app itself, and a [`pywb-webrecorder`](https://github.com/Treora/pywb-webrecorder/tree/webmemex) instance, which is used to proxy viewed webpages while inserting some extra code into them to detect and report link clicks (because the application should open clicked links in a new iframe). 23 | 24 | To run things yourself: 25 | 26 | 1. Get [Docker](http://docker.com) and [Node/NPM][Node]. 27 | 2. Clone and run [this `pywb-webrecorder` fork](https://github.com/Treora/pywb-webrecorder/tree/webmemex) (`make rebuild start`, or something). 28 | 3. Clone and run `webmemex` (`make build run`). Then visit `localhost:8086` in your browser. 29 | 30 | ## Code tour 31 | 32 | The whole thing is just an HTML page/app itself, using `