├── Setup.hs
├── client
├── src
│ ├── config.js
│ ├── ui
│ │ ├── colors.less
│ │ ├── spinner.js
│ │ ├── hoverMenuButton.js
│ │ ├── arrow.js
│ │ ├── menuItem.js
│ │ └── menu.js
│ ├── debug.js
│ ├── effects
│ │ ├── systemMenu.js
│ │ ├── index.js
│ │ ├── viewMenu.js
│ │ ├── editMode.js
│ │ └── spells.js
│ ├── flags.js
│ ├── indicateLoading.js
│ ├── index.js
│ ├── Client.js
│ └── StateMachine.js
├── build
│ ├── right_click.png
│ └── comic.html
├── postcss.config.js
├── webpack.config.babel.js
└── package.json
├── ChangeLog.md
├── Main.hs
├── src
└── Alto
│ ├── Compile
│ ├── BitPuzzle.hs
│ └── Navigations.hs
│ ├── Example.hs
│ ├── Web.hs
│ ├── Menu.hs
│ └── Compile.hs
├── LICENSE
└── alto.cabal
/Setup.hs:
--------------------------------------------------------------------------------
1 | import Distribution.Simple
2 | main = defaultMain
3 |
--------------------------------------------------------------------------------
/client/src/config.js:
--------------------------------------------------------------------------------
1 | export const ALTO_ENDPOINT = 'https://xkcd.com/1975/alto'
2 |
--------------------------------------------------------------------------------
/client/src/ui/colors.less:
--------------------------------------------------------------------------------
1 | @itemBackgroundColor: #e4e4e4;
2 | @itemHighlightColor: #5544ff;
3 |
--------------------------------------------------------------------------------
/client/build/right_click.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xkcd/alto/HEAD/client/build/right_click.png
--------------------------------------------------------------------------------
/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('autoprefixer')
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/ChangeLog.md:
--------------------------------------------------------------------------------
1 | # Revision history for alto
2 |
3 | ## 0 -- YYYY-mm-dd
4 |
5 | * First version. Released on an unsuspecting world.
6 |
--------------------------------------------------------------------------------
/Main.hs:
--------------------------------------------------------------------------------
1 | module Main where
2 |
3 | import Alto.Web
4 | import Alto.Menu
5 | import Network.Wai.Handler.Warp
6 |
7 | main :: IO ()
8 | main = do
9 | conf <- AltoConfig <$> loadMenus <*> pure (ClientState mempty)
10 | run 8081 (altoApp conf)
11 |
--------------------------------------------------------------------------------
/client/build/comic.html:
--------------------------------------------------------------------------------
1 |

2 |
--------------------------------------------------------------------------------
/client/src/debug.js:
--------------------------------------------------------------------------------
1 | import {DEBUG} from './flags'
2 |
3 | export default {
4 | log(...args) {
5 | if (!DEBUG) {
6 | return
7 | }
8 | console.log(...args)
9 | },
10 |
11 | warn(...args) {
12 | if (!DEBUG) {
13 | return
14 | }
15 | console.warn(...args)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Alto/Compile/BitPuzzle.hs:
--------------------------------------------------------------------------------
1 | module Alto.Compile.BitPuzzle where
2 |
3 | -- | Takes a menu, and gates access to it on someone's ability to "unlock"
4 | -- access through pushing switches that fip various bits.
5 | -- Difficulty is based on number of bits, and number of flips done.
6 | genBitLock :: Menu -> Int -> Int -> MenuM Menu
7 | genBitLock m bitCnt flipCnt = do
8 |
--------------------------------------------------------------------------------
/client/src/effects/systemMenu.js:
--------------------------------------------------------------------------------
1 | import { fullScreen } from './viewMenu';
2 |
3 | export function shutDown() {
4 | const hasUpdates = Math.random() > 0.1;
5 | const shutdownScreen = hasUpdates ? 'pdnpoouym.jpg' : 'e05.jpg';
6 | const img = new Image(window.innerWidth, window.innerHeight);
7 | img.src = '//xkcd.com/1975/' + shutdownScreen;
8 | document.body.innerHTML = '';
9 | document.body.appendChild(img);
10 | }
--------------------------------------------------------------------------------
/client/src/flags.js:
--------------------------------------------------------------------------------
1 | const hash = location.hash.substr(1)
2 | const flags = new Map()
3 | if (hash.length) {
4 | for (const flag of hash.split(',')) {
5 | const parts = flag.split('=', 2)
6 | if (parts.length === 2) {
7 | flags.set(parts[0], parts[1])
8 | } else {
9 | flags.set(flag, true)
10 | }
11 | }
12 | }
13 |
14 | export const DEBUG = flags.has('debug')
15 | export const ENDPOINT = flags.get('endpoint')
16 |
--------------------------------------------------------------------------------
/client/src/effects/index.js:
--------------------------------------------------------------------------------
1 | const effectMap = new Map()
2 |
3 | const requireEffectModule = require.context('./', false, /\.js$/)
4 | for (const key of requireEffectModule.keys()) {
5 | if (key === './index.js') {
6 | continue
7 | }
8 |
9 | const module = requireEffectModule(key)
10 | for (const [name, func] of Object.entries(module)) {
11 | if (effectMap.has(name)) {
12 | throw `duplicate name: ${name}`
13 | }
14 |
15 | effectMap.set(name, func)
16 | }
17 | }
18 |
19 | export default effectMap
20 |
--------------------------------------------------------------------------------
/client/src/effects/viewMenu.js:
--------------------------------------------------------------------------------
1 | /* sure, minimize the mouse cursor, that's what we meant */
2 | /* TODO ask folks how to upload a static asset */
3 | export function viewMinimize() {
4 | document.body.style.cursor = "url(//xkcd.com/1975/Tiny.cur), default";
5 | }
6 |
7 | /* full screen it is! */
8 | export function fullScreen() {
9 | const methodName = [
10 | "requestFullscreen",
11 | "webkitRequestFullscreen",
12 | "mozRequestFullScreen",
13 | "msRequestFullscreen"
14 | ].find(fname => typeof comic[fname] === "function");
15 | if (methodName) {
16 | document.body[methodName]();
17 | }
18 | }
--------------------------------------------------------------------------------
/client/src/indicateLoading.js:
--------------------------------------------------------------------------------
1 | export default function indicateLoading(updateLoading, waitMs = 200) {
2 | let canceled = false
3 | let finished = false
4 | let loading = false
5 |
6 | setTimeout(() => {
7 | if (!finished && !canceled) {
8 | loading = true
9 | updateLoading(true)
10 | }
11 | }, waitMs)
12 |
13 | return {
14 | get isLoading() {
15 | return loading
16 | },
17 |
18 | finished() {
19 | finished = true
20 | if (loading && !canceled) {
21 | loading = false
22 | updateLoading(false)
23 | }
24 | },
25 |
26 | cancel() {
27 | canceled = true
28 | },
29 | }
30 | }
31 |
32 |
--------------------------------------------------------------------------------
/src/Alto/Example.hs:
--------------------------------------------------------------------------------
1 | {-# Language OverloadedStrings #-}
2 | module Alto.Example where
3 |
4 | import Alto.Compile
5 | import Alto.Menu
6 |
7 | exampleMenu :: IO MenuSystem
8 | exampleMenu = compileRoot "Example Menu" $ do
9 | de <- menu $ ent "Dead end!"
10 | menu $ do
11 | ent "This is like a header"
12 | ent $ "Sub Menu" |-> de
13 | -- ent "A directly defined submenu" $ do
14 | -- ent "This is an entry in a directly defined submenu."
15 | -- A sometimes hidden menu that
16 | hideIt <- uniqueTag "Hide it" -- Makes sure the tag isn't used by something else
17 | ent $ "Hide me!" &- hideIt |-+ hideIt
18 | ent $ "Unhide it" &+ hideIt |-- hideIt
19 | ent "Final entry"
20 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import html from 'nanohtml'
2 |
3 | import {ENDPOINT} from './flags'
4 | import {ALTO_ENDPOINT} from './config'
5 | import Client from './Client'
6 | import effectMap from './effects'
7 | import StateMachine from './StateMachine'
8 | import {attachMenuTo} from './ui/menu'
9 |
10 | async function main() {
11 | const client = new Client(ENDPOINT || ALTO_ENDPOINT)
12 | const state = new StateMachine(client, effectMap)
13 |
14 | await state.init()
15 |
16 | attachMenuTo({
17 | triggerEl: document.querySelector('#comic'),
18 | id: null,
19 | itemGen: state.itemGen.bind(state),
20 | onMenuSelect: state.handleSelect.bind(state),
21 | onMenuEnter: state.handleEnter.bind(state),
22 | onMenuLeave: state.handleLeave.bind(state),
23 | })
24 | }
25 |
26 | main()
27 |
--------------------------------------------------------------------------------
/client/webpack.config.babel.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import webpack from 'webpack'
3 |
4 | export default {
5 | entry: {
6 | comic: './src/index.js',
7 | },
8 |
9 | output: {
10 | filename: '[name].js',
11 | path: path.join(__dirname, 'build'),
12 | },
13 |
14 | module: {
15 | rules: [
16 | {
17 | test: /\.css$/,
18 | use: [
19 | { loader: 'style-loader', options: { singleton: true } },
20 | { loader: 'css-loader', options: { modules: true } },
21 | 'postcss-loader',
22 | 'less-loader',
23 | ],
24 | },
25 | {
26 | test: /\.js$/,
27 | exclude: /node_modules/,
28 | use: ['babel-loader', 'css-literal-loader'],
29 | },
30 | ],
31 | },
32 |
33 | plugins: [
34 | new webpack.BannerPlugin('alto client by chromako.de'),
35 | ],
36 | }
37 |
--------------------------------------------------------------------------------
/client/src/ui/spinner.js:
--------------------------------------------------------------------------------
1 | import html from 'nanohtml'
2 |
3 | export const styles = css`
4 | @keyframes spin {
5 | from {
6 | transform: rotate(0deg);
7 | }
8 | to {
9 | transform: rotate(360deg);
10 | }
11 | }
12 |
13 | .spinner {
14 | display: flex;
15 | margin: 0 -2px;
16 | opacity: .5;
17 | animation: spin 1s linear infinite;
18 | }
19 | `
20 |
21 | export default function spinner(props) {
22 | const {size = 17, strokeWidth = 2} = props
23 | const r = size / 2 - strokeWidth
24 |
25 | const el = html`
26 |
38 | `
39 | return el
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/ui/hoverMenuButton.js:
--------------------------------------------------------------------------------
1 | import html from 'nanohtml'
2 | import morph from 'nanomorph'
3 |
4 | import arrow from './arrow'
5 |
6 | export default function hoverMenuButton(props) {
7 | const {direction, onTrigger, className, intervalMs = 10} = props
8 |
9 | let el
10 | let hoverInterval
11 | let isHighlighted = false
12 |
13 | function handleMouseEnter(ev) {
14 | hoverInterval = setInterval(() => onTrigger(ev), intervalMs)
15 | isHighlighted = true
16 | morph(el, render())
17 | }
18 |
19 | function handleMouseLeave() {
20 | clearInterval(hoverInterval)
21 | isHighlighted = false
22 | morph(el, render())
23 | }
24 |
25 | function render() {
26 | return html`
27 |
33 | ${arrow({direction, color: isHighlighted ? 'white' : 'black'})}
34 |
35 | `
36 | }
37 |
38 | el = render()
39 |
40 | return el
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "alto-client",
3 | "description": "A cloud-based menu system",
4 | "version": "0.0.1",
5 | "license": "MIT",
6 | "private": true,
7 | "scripts": {
8 | "start": "webpack-dev-server --mode development --hot --inline --content-base build/",
9 | "build": "webpack --mode production -p --progress"
10 | },
11 | "dependencies": {
12 | "babel-runtime": "^6.26.0",
13 | "dom-css": "^2.1.0",
14 | "nanoid": "^1.0.2",
15 | "nanohtml": "^1.2.2",
16 | "nanomorph": "^5.1.3",
17 | "unfetch": "^3.0.0"
18 | },
19 | "devDependencies": {
20 | "autoprefixer": "^8.2.0",
21 | "babel-core": "^6.26.0",
22 | "babel-loader": "^7.1.4",
23 | "babel-preset-env": "^1.6.1",
24 | "babel-plugin-transform-object-rest-spread": "^6.26.0",
25 | "babel-plugin-transform-runtime": "^6.23.0",
26 | "css-literal-loader": "^0.4.5",
27 | "css-loader": "^0.28.11",
28 | "less": "^3.0.1",
29 | "less-loader": "^4.1.0",
30 | "postcss-loader": "^2.1.3",
31 | "style-loader": "^0.20.3",
32 | "webpack": "^4.2.0",
33 | "webpack-cli": "^2.0.13",
34 | "webpack-dev-server": "^3.1.1"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/client/src/Client.js:
--------------------------------------------------------------------------------
1 | import fetch from 'unfetch'
2 | import nanoid from 'nanoid/generate'
3 | import nanoidChars from 'nanoid/url'
4 |
5 | export default class Client {
6 | constructor(baseURL) {
7 | this.baseURL = baseURL
8 | this.cache = new Map()
9 | this.sessionId = nanoid(nanoidChars.substr(2), 22)
10 | }
11 |
12 | async get(id) {
13 | if (this.cache.has(id)) {
14 | return await this.cache.get(id)
15 | }
16 |
17 | let path
18 | if (id === null) {
19 | path = '/root'
20 | } else {
21 | path = `/menu/${id}`
22 | }
23 |
24 | const dataFetch = fetch(this.baseURL + path).then(resp => resp.json())
25 | this.cache.set(id, dataFetch)
26 |
27 | const data = await dataFetch
28 | if (data.Menu) {
29 | this.cache.set(data.Menu.id, Promise.resolve(data.Menu))
30 | }
31 | return data
32 | }
33 |
34 | logEnter(parentId, menuId) {
35 | fetch(`${this.baseURL}/enter/${this.sessionId}/${parentId}/${menuId}?${Date.now()}`).catch(e => {})
36 | }
37 |
38 | logVisit(menuId, entryIdx) {
39 | fetch(`${this.baseURL}/visit/${this.sessionId}/${menuId}/${entryIdx}?${Date.now()}`).catch(e => {})
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/client/src/ui/arrow.js:
--------------------------------------------------------------------------------
1 | import html from 'nanohtml'
2 | import style from 'dom-css'
3 |
4 | export const styles = css`
5 | .arrow {
6 | width: 0;
7 | height: 0;
8 | opacity: .65;
9 | border: 5px solid transparent;
10 |
11 | &.up {
12 | border-bottom: 5px solid black;
13 | }
14 |
15 | &.down {
16 | border-top: 5px solid black;
17 | }
18 |
19 | &.left {
20 | border-left: 5px solid black;
21 | }
22 |
23 | &.right {
24 | border-right: 5px solid black;
25 | }
26 | }
27 | `
28 |
29 | export default function arrow(props) {
30 | const {direction, size = 5, color = 'black'} = props
31 |
32 | const directionStyles = {
33 | 'up': {
34 | borderBottom: `${size}px solid ${color}`,
35 | },
36 | 'down': {
37 | borderTop: `${size}px solid ${color}`,
38 | },
39 | 'left': {
40 | borderRight: `${size}px solid ${color}`,
41 | },
42 | 'right': {
43 | borderLeft: `${size}px solid ${color}`,
44 | },
45 | }
46 |
47 | if (!directionStyles.hasOwnProperty(direction)) {
48 | throw 'invalid direction'
49 | }
50 |
51 | const el = html``
52 | style(el, directionStyles[direction])
53 |
54 | return el
55 | }
56 |
57 |
--------------------------------------------------------------------------------
/src/Alto/Compile/Navigations.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE OverloadedStrings #-}
2 | module Alto.Compile.Navigations where
3 |
4 | import Alto.Menu
5 | import Alto.Compile
6 | import Control.Lens
7 | import Control.Monad
8 | import Control.Monad.Trans
9 | import Data.ListTrie.Patricia.Map (TrieMap)
10 | import qualified Data.ListTrie.Patricia.Map as LTP
11 | import Data.Map (Map)
12 | import qualified Data.Text as T
13 |
14 | -- | Makes a Patricia Trie into a set of actions.
15 | -- Doesn't handle all Tries.
16 | trieMenu :: [(String, EntryType)] -> MenuM Menu
17 | trieMenu =
18 | go . LTP.fromList
19 | where
20 | go :: TrieMap Map Char EntryType -> MenuM Menu
21 | go = menu . breakPre
22 | breakPre :: TrieMap Map Char EntryType -> EntryM ()
23 | breakPre t = do
24 | iforM_ (LTP.children1 t) $ \fc st -> do
25 | case LTP.splitPrefix . LTP.addPrefix [fc] $ st of
26 | (pfx, Nothing, st') -> do
27 | sbmn <- lift $ go st'
28 | ent . MEntry Nothing (T.pack pfx) Always Always $ mnAction sbmn
29 | (pfx, Just a, st') -> do
30 | ent . MEntry Nothing (T.pack pfx) Always Always $ a
31 | unless (LTP.null st') $ do
32 | breakPre . LTP.addPrefix pfx $ st'
33 |
34 |
--------------------------------------------------------------------------------
/src/Alto/Web.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE TemplateHaskell #-}
2 | {-# LANGUAGE DataKinds #-}
3 | {-# LANGUAGE TypeOperators #-}
4 | {-# LANGUAGE OverloadedStrings #-}
5 | module Alto.Web where
6 |
7 | import Alto.Menu
8 | import Control.Lens
9 | import Data.Text (Text)
10 | import Servant
11 |
12 | data AltoConfig =
13 | AltoConfig
14 | { _mSys :: MenuSystem
15 | , _initState :: ClientState
16 | }
17 | deriving (Read, Show, Eq, Ord)
18 |
19 | makeLenses ''AltoConfig
20 |
21 | type RootAPI = "root" :> Get '[JSON] Root
22 | type MenuAPI = "menu" :> Capture "menuid" MenuID :> Get '[JSON] (Headers '[Header "Cache-Control" Text] Menu)
23 | -- type CallbackAPI = "callback" :> ReqBody '[PlainText] ByteString :> Post '[JSON] Event
24 |
25 | type API = RootAPI :<|> MenuAPI
26 |
27 | api :: Proxy API
28 | api = Proxy
29 |
30 | serveRoot :: AltoConfig -> Handler Root
31 | serveRoot cfg = return $ MenuRoot (cfg^.initState) (cfg^.mSys.topMenu)
32 |
33 | serveMenu :: AltoConfig -> Text -> Handler Menu
34 | serveMenu cfg i =
35 | case cfg ^. mSys.menuMap.at i of
36 | Nothing -> throwError err404
37 | Just m -> return m
38 |
39 | addCacheHeader :: Handler Menu -> Handler (Headers '[Header "Cache-Control" Text] Menu)
40 | addCacheHeader = fmap (addHeader "public, max-age=1")
41 |
42 | altoServer :: AltoConfig -> Server API
43 | altoServer cfg = serveRoot cfg :<|> (addCacheHeader <$> serveMenu cfg)
44 |
45 | altoApp :: AltoConfig -> Application
46 | altoApp c = serve api (altoServer c)
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2018, davean
2 |
3 | All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 |
11 | * Redistributions in binary form must reproduce the above
12 | copyright notice, this list of conditions and the following
13 | disclaimer in the documentation and/or other materials provided
14 | with the distribution.
15 |
16 | * Neither the name of davean nor the names of other
17 | contributors may be used to endorse or promote products derived
18 | from this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31 |
--------------------------------------------------------------------------------
/client/src/effects/editMode.js:
--------------------------------------------------------------------------------
1 | export function editMode() {
2 |
3 | var canvas = document.createElement('canvas');
4 | canvas.style.position = 'fixed';
5 | canvas.style.left = '0';
6 | canvas.style.top = '0';
7 | canvas.width = window.innerWidth;
8 | canvas.height = window.innerHeight;
9 | document.body.appendChild(canvas);
10 |
11 | var context = canvas.getContext('2d');
12 | context.strokeStyle = 'black';
13 | context.lineJoin = 'round';
14 | context.lineWidth = 3;
15 |
16 | function resumePaint(e) {
17 | context.beginPath();
18 | context.moveTo(e.clientX, e.clientY);
19 | }
20 |
21 | function paint(e) {
22 | context.lineTo(e.clientX, e.clientY);
23 | context.stroke();
24 | }
25 |
26 | function startPaint(e) {
27 | if (e.which === 1) { // left button
28 | document.body.addEventListener('mouseover', resumePaint);
29 | document.body.addEventListener('mousemove', paint);
30 | document.body.addEventListener('mouseup', endPaint);
31 | }
32 | }
33 |
34 | function endPaint(e) {
35 | document.body.removeEventListener('mouseover', resumePaint);
36 | document.body.removeEventListener('mousemove', paint);
37 | document.body.removeEventListener('mouseup', endPaint);
38 | }
39 |
40 | window.addEventListener('keyup', function escapeOut(e) {
41 | if (e.which === 27 && window.confirm('Aw, that looks nice though. Really delete?')) {
42 | document.body.style.cursor = 'auto';
43 | document.body.style.backgroundImage = '';
44 | document.body.removeChild(canvas);
45 | window.removeEventListener('keyup', escapeOut);
46 | }
47 | });
48 |
49 | document.body.addEventListener('mousedown', startPaint);
50 | document.body.style.cursor = 'url(//xkcd.com/1975/brush.cur), crosshair';
51 | document.body.style.backgroundImage = 'url(//xkcd.com/1975/transparency.png)';
52 |
53 | }
--------------------------------------------------------------------------------
/alto.cabal:
--------------------------------------------------------------------------------
1 | name: alto
2 | version: 0
3 | synopsis: Impliment a menu experience fit for web users.
4 | description:
5 | A system for building cloud scale menu systems.
6 |
7 | For an example, see .
8 | homepage: https://oss.xkcd.com/
9 | license: BSD3
10 | license-file: LICENSE
11 | author: davean
12 | maintainer: davean@xkcd.com
13 | copyright: davean 2018
14 | category: Web
15 | build-type: Simple
16 | extra-source-files: ChangeLog.md
17 | cabal-version: >=1.10
18 |
19 | extra-source-files:
20 | client/src/effects/*.js
21 | client/src/ui/*.js
22 | client/src/*.js
23 | client/build/comic.html
24 | client/build/right_click.png
25 | client/package.json
26 | client/webpack.config.babel.js
27 | client/postcss.config.js
28 |
29 | source-repository head
30 | type: git
31 | location: https://code.xkrd.net/xkcd/alto.git
32 |
33 | library
34 | hs-source-dirs: src
35 | default-language: Haskell2010
36 | exposed-modules:
37 | Alto.Menu
38 | , Alto.Compile
39 | , Alto.Compile.Navigations
40 | , Alto.Example
41 | , Alto.Web
42 | build-depends:
43 | base >=4.10 && <4.12
44 | , mtl == 2.2.*
45 | , text == 1.2.*
46 | , containers == 0.5.*
47 | , bytestring == 0.10.*
48 | , lens == 4.16.*
49 | , cryptohash-sha256 == 0.11.*
50 | , scrypt == 0.5.*
51 | , base64-bytestring == 1.0.*
52 | , aeson == 1.3.*
53 | , servant-server == 0.13.*
54 | , filepath == 1.4.*
55 | , directory == 1.3.*
56 | , random-string == 0.1.*
57 | , list-tries == 0.6.*
58 | , MonadRandom == 0.5.*
59 | , random == 1.1.*
60 | , exceptions == 0.10.*
61 |
62 | executable alto
63 | -- other-modules:
64 | -- other-extensions:
65 | -- hs-source-dirs: src
66 | default-language: Haskell2010
67 | main-is: Main.hs
68 | build-depends:
69 | base >=4.10 && <4.12
70 | , alto
71 | , warp == 3.2.*
72 |
--------------------------------------------------------------------------------
/client/src/ui/menuItem.js:
--------------------------------------------------------------------------------
1 | import html from 'nanohtml'
2 |
3 | import arrow from './arrow'
4 | import spinner from './spinner'
5 |
6 | export const styles = css`
7 | @import "./colors.less";
8 |
9 | .item {
10 | display: flex;
11 | color: black;
12 | border-radius: 2px;
13 | align-items: center;
14 | cursor: default;
15 | padding: 8px 10px;
16 | user-select: none;
17 | -webkit-tap-highlight-color: transparent;
18 |
19 | &.highlight {
20 | background-color: @itemHighlightColor;
21 | color: white;
22 | }
23 |
24 | &.disabled {
25 | opacity: .5;
26 | }
27 |
28 | & > span {
29 | flex: 1;
30 | }
31 |
32 | &.disabled > span {
33 | text-shadow: 0 1px white;
34 | }
35 | }
36 |
37 | &.left {
38 | & > span {
39 | margin-left: 10px;
40 | }
41 |
42 | & > .spacer {
43 | justify-content: flex-start;
44 | }
45 | }
46 |
47 | &.right {
48 | & > .spacer {
49 | justify-content: flex-end;
50 | }
51 |
52 | & > span {
53 | margin-right: 10px;
54 | }
55 | }
56 |
57 | .spacer {
58 | display: flex;
59 | width: 15px;
60 | }
61 | `
62 |
63 | export default function menuItem(props) {
64 | const {item, showArrows, isLoading, isHighlighted, onItemSelect, onMouseEnter, onMouseLeave, attach} = props
65 |
66 | let preEdgeEl
67 | let postEdgeEl
68 | if (showArrows) {
69 | let edgeEl
70 | if (isLoading) {
71 | edgeEl = spinner({})
72 | } else if (item.subMenuId) {
73 | edgeEl = arrow({direction: attach.x, color: isHighlighted ? 'white' : 'black'})
74 | }
75 |
76 | const edgeSpacer = html`
77 |
78 | ${edgeEl}
79 |
80 | `
81 | if (attach.x === 'left') {
82 | preEdgeEl = edgeSpacer
83 | } else if (attach.x === 'right') {
84 | postEdgeEl = edgeSpacer
85 | }
86 | }
87 |
88 | const classes = [
89 | styles.item,
90 | showArrows && styles[attach.x],
91 | isHighlighted && styles.highlight,
92 | item.disabled && styles.disabled,
93 | ].filter(x => x)
94 |
95 | const el = html`
96 | onItemSelect(item, ev.target)}
99 | onmouseenter=${item.disabled ? null : ev => onMouseEnter(item, ev.target)}
100 | onmouseleave=${item.disabled ? null : ev => onMouseLeave(item, ev.target)}
101 | >
102 | ${preEdgeEl}
103 | ${item.label}
104 | ${postEdgeEl}
105 |
106 | `
107 |
108 | return el
109 | }
110 |
--------------------------------------------------------------------------------
/client/src/StateMachine.js:
--------------------------------------------------------------------------------
1 | import html from 'nanohtml'
2 |
3 | import debug from './debug'
4 |
5 | export default class StateMachine {
6 | constructor(client, effectMap) {
7 | this.client = client
8 | this.effectMap = effectMap
9 | this.tags = null
10 | this.rootId = null
11 | }
12 |
13 | async init() {
14 | let root = await this.client.get(null)
15 | this.tags = new Map(Object.entries(root.State.Tags))
16 | this.rootId = root.Menu.id
17 | this.prefetch(this.rootId, 2)
18 | }
19 |
20 | evalTagLogic(whenTree) {
21 | const evalMap = {
22 | 'Always': () => true,
23 | 'TLAnd': x => x.contents.every(exec),
24 | 'TLOr': x => x.contents.some(exec),
25 | 'TLNot': x => !exec(x.contents),
26 | 'TagSet': x => this.tags.has(x.contents),
27 | 'TagUnset': x => !this.tags.has(x.contents),
28 | }
29 |
30 | function exec(x) {
31 | if (!evalMap.hasOwnProperty(x.tag)) {
32 | throw 'unexpected tag logic operator'
33 | }
34 | return evalMap[x.tag](x)
35 | }
36 |
37 | return exec(whenTree)
38 | }
39 |
40 | evalSubMenuId(reaction) {
41 | if (!reaction.tag === 'SubMenu') {
42 | return
43 | }
44 |
45 | const {subMenu, subIdPostfix} = reaction
46 | if (subIdPostfix) {
47 | return subMenu + this.tags.get(subIdPostfix)
48 | }
49 | return subMenu
50 | }
51 |
52 | async prefetch(id, depth=1) {
53 | let fetches = []
54 | let data = await this.client.get(id)
55 | for (const entry of data.entries) {
56 | if (!this.evalTagLogic(entry.display)) {
57 | continue
58 | }
59 |
60 | if (entry.reaction.subIdPostfix) {
61 | // don't prefetch ids containing dynamic tags
62 | continue
63 | }
64 |
65 | const subMenuId = this.evalSubMenuId(entry.reaction)
66 | if (!subMenuId) {
67 | continue
68 | }
69 |
70 | fetches.push(this.client.get(subMenuId))
71 |
72 | if (depth > 1) {
73 | fetches.push(this.prefetch(subMenuId, depth - 1))
74 | }
75 | }
76 |
77 | return Promise.all(fetches)
78 | }
79 |
80 | async itemGen(id) {
81 | if (id === null) {
82 | const rootData = await this.client.get(this.rootId)
83 |
84 | // the root menu is special. find the first displayed submenu of the root.
85 | for (const entry of rootData.entries) {
86 | const {display, reaction} = entry
87 | if (this.evalTagLogic(display)) {
88 | id = this.evalSubMenuId(reaction)
89 | break
90 | }
91 | }
92 | }
93 |
94 | const data = await this.client.get(id)
95 |
96 | let menuItems = []
97 | for (let idx = 0; idx < data.entries.length; idx++) {
98 | const entry = data.entries[idx]
99 | const {display, active, reaction} = entry
100 |
101 | if (!this.evalTagLogic(display)) {
102 | continue
103 | }
104 |
105 | menuItems.push({
106 | menuId: id,
107 | entryIdx: idx,
108 | idx: menuItems.length,
109 | label: entry.label,
110 | disabled: active && !this.evalTagLogic(active),
111 | subMenuId: this.evalSubMenuId(reaction),
112 | })
113 | }
114 |
115 | this.prefetch(id)
116 | return menuItems
117 | }
118 |
119 | performAction(act) {
120 | if (act.tag === 'Nav') {
121 | window.open(act.url)
122 | } else if (act.tag === 'Download') {
123 | try {
124 | const a = html`
125 |
129 | `
130 | a.click()
131 | } catch (err) {
132 | window.open(act.url)
133 | }
134 | } else if (act.tag === 'JSCall') {
135 | const effectName = act.jsCall
136 | if (!this.effectMap.has(effectName)) {
137 | debug.warn('missing effect:', effectName)
138 | return
139 | }
140 | this.effectMap.get(effectName)()
141 | }
142 | }
143 |
144 | updateTags(tagChange) {
145 | const {setTags, unsetTags} = tagChange
146 | let changes = {set: [], deleted: []}
147 |
148 | if (setTags) {
149 | for (const [key, value] of Object.entries(setTags)) {
150 | this.tags.set(key, value)
151 | changes.set.push([key, value])
152 | }
153 | }
154 |
155 | if (unsetTags) {
156 | for (const key of unsetTags) {
157 | this.tags.delete(key)
158 | changes.deleted.push(key)
159 | }
160 | }
161 |
162 | if (changes.set.length || changes.deleted.length) {
163 | debug.log('tags changed:', changes)
164 | }
165 | }
166 |
167 | async handleEnter(menuId, entryIdx, subMenuId) {
168 | const {entries} = await this.client.get(menuId)
169 | const {reaction} = entries[entryIdx]
170 |
171 | if (reaction.onAction) {
172 | this.updateTags(reaction.onAction)
173 | }
174 |
175 | this.client.logEnter(menuId, subMenuId)
176 | }
177 |
178 | async handleSelect(menuId, entryIdx) {
179 | const {entries} = await this.client.get(menuId)
180 | const {reaction} = entries[entryIdx]
181 |
182 | if (reaction.onAction) {
183 | this.updateTags(reaction.onAction)
184 | }
185 |
186 | if (reaction.act) {
187 | this.performAction(reaction.act)
188 | }
189 |
190 | this.client.logVisit(menuId, entryIdx)
191 | }
192 |
193 | async handleLeave(menuId) {
194 | const {onLeave} = await this.client.get(menuId)
195 |
196 | if (onLeave) {
197 | this.updateTags(onLeave)
198 | }
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/client/src/effects/spells.js:
--------------------------------------------------------------------------------
1 | /* themes */
2 | export function darkTheme () {
3 | document.body.style.background = "#000";
4 | document.body.style.color = "darkred";
5 | var s= document.getElementById("comic").childNodes[1].style;
6 | s.webkitFilter = "invert(1)";
7 | s.filter = "invert(1)";
8 | // TODO menu colors
9 | }
10 |
11 | /* make screen black */
12 | export function darkness() {
13 | document.body.style.background = "#000";
14 | document.body.style.color = "#000";
15 | var s= document.getElementById("comic").childNodes[1].style;
16 | s.opacity = 0;
17 | s.MozOpacity = 0;
18 | s.KhtmlOpacity = 0;
19 | s.filter = 'alpha(opacity=0)';
20 | // TODO? this doesn't do anything to the menu
21 | }
22 |
23 | /* grayscale */
24 | export function darkVision() {
25 | document.body.style.background = "darkgray";
26 | document.body.style.color = "lightgray";
27 | var s= document.getElementById("comic").childNodes[1].style;
28 | s.opacity = .25;
29 | s.MozOpacity = .25;
30 | s.KhtmlOpacity = .25;
31 | s.filter = 'alpha(opacity=.25)';
32 | // TODO grayscale the menu as well
33 | }
34 |
35 | /* explosion */
36 | export function fireball() {
37 | var reds = ["#ff0000", "#ff0000", "#ff0000", "#ff0000", "#ee0000", "#ee0000", "#dd0000", "#cc0000", "#bb0000", "#aa0000", "#990000", "#880000", "#770000", "#660000", "#550000", "#440000", "#330000", "#220000", "#110000", "#000", "#000" ];
38 | var timesRun = 0;
39 | var s= document.getElementById("comic").childNodes[1].style;
40 | s.opacity = 0.25;
41 | s.MozOpacity = 0.25;
42 | s.KhtmlOpacity = 0.25;
43 | s.filter = 'alpha(opacity=0.25)';
44 | document.body.style.background = reds[0];
45 | var timer = setInterval(function() {
46 | if ( timesRun <= reds.length ) {
47 | document.body.style.background = reds[timesRun];
48 | timesRun++;
49 | }
50 | else {
51 | document.body.style.background = "#fff";
52 | s.opacity = 1;
53 | s.MozOpacity = 1;
54 | s.KhtmlOpacity = 1;
55 | s.filter = 'alpha(opacity=1)';
56 | clearInterval(timer);
57 | }
58 | }, 100);
59 | }
60 |
61 | /* add a bun */
62 | export function conjureAnimals() {
63 | var img = document.createElement("img");
64 | img.src = "//xkcd.com/1975/kat-bun-small.png";
65 | img.height = Math.floor(Math.random()*(100-30+1)+30);
66 | if (Math.floor(Math.random() * 2) == 0) {
67 | img.style.webkitTransform = "scale(-1,1)";
68 | img.style.mozTransform = "scale(-1,1)";
69 | img.style.oTransform = "scale(-1,1)";
70 | img.style.msTransform = "scale(-1,1)";
71 | img.style.transform = "scale(-1,1)";
72 | }
73 | document.getElementById("comic").appendChild(img);
74 | }
75 |
76 | /* change the font to a symbol font */
77 | export function symbol() {
78 | document.body.style.fontFamily = "Wingdings,Webdings,Symbol,Zapf Dingbats";
79 | var menus = document.getElementsByTagName("li");
80 | for(i=0; i) (TagChange a1 a2) (TagChange b1 b2) = TagChange (a1 <> b1) (a2 <> b2)
56 |
57 | instance Monoid TagChange where
58 | mempty = TagChange mempty mempty
59 |
60 | makeLenses ''TagChange
61 | JS.deriveJSON JS.defaultOptions{JS.fieldLabelModifier = drop 1, JS.sumEncoding = JS.UntaggedValue} ''TagChange
62 |
63 | data EmbedSize =
64 | EFullPage
65 | | ENative
66 | | ESize { _x :: Int, _y :: Int }
67 | deriving (Read, Show, Eq, Ord, Generic)
68 |
69 | makeLenses ''EmbedSize
70 | JS.deriveJSON JS.defaultOptions{JS.fieldLabelModifier = drop 1} ''EmbedSize
71 |
72 | data Action =
73 | ColapseMenu
74 | | Nav { _url :: Text }
75 | | Embed { _url :: Text, _size :: EmbedSize }
76 | | Download { _url :: Text, _filename :: Text }
77 | | JSCall { _jsCall :: Text }
78 | deriving (Read, Show, Eq, Ord, Generic)
79 |
80 | makeLenses ''Action
81 | JS.deriveJSON JS.defaultOptions{JS.fieldLabelModifier = drop 1} ''Action
82 |
83 | data EntryType =
84 | Action { _onAction :: TagChange, _act :: Maybe Action }
85 | -- ^ When the entry is clicked it does the above
86 | | SubMenu { _onAction :: TagChange, _subMenu :: MenuID, _subIdPostfix :: Maybe Tag }
87 | -- ^ When the entry is selected, the submenu is displayed
88 | -- | CallBack SomeHMACedThing
89 | deriving (Read, Show, Eq, Ord, Generic)
90 |
91 | makeLenses ''EntryType
92 | JS.deriveJSON JS.defaultOptions{JS.fieldLabelModifier = drop 1} ''EntryType
93 |
94 | data MenuEntry =
95 | MEntry
96 | { _icon :: Maybe Text
97 | , _label :: Text
98 | , _display :: TagLogic
99 | , _active :: TagLogic
100 | , _reaction :: EntryType
101 | }
102 | deriving (Read, Show, Eq, Ord, Generic)
103 |
104 | makeLenses ''MenuEntry
105 | JS.deriveJSON JS.defaultOptions{JS.fieldLabelModifier = drop 1} ''MenuEntry
106 |
107 | instance IsString MenuEntry where
108 | fromString l = MEntry Nothing (T.pack l) Always Always (Action mempty Nothing)
109 |
110 | data Menu =
111 | Menu
112 | { _mid :: MenuID
113 | , _onLeave :: TagChange
114 | , _entries :: [MenuEntry]
115 | }
116 | deriving (Read, Show, Eq, Ord, Generic)
117 |
118 | makeLenses ''Menu
119 | JS.deriveJSON JS.defaultOptions{JS.fieldLabelModifier = dropWhile (=='m') . drop 1} ''Menu
120 |
121 | data Root =
122 | MenuRoot
123 | { _rootState :: ClientState
124 | , _rootMenu :: Menu
125 | }
126 | deriving (Read, Show, Eq, Ord, Generic)
127 |
128 | makeLenses ''Root
129 | JS.deriveJSON JS.defaultOptions{JS.fieldLabelModifier = drop 5} ''Root
130 |
131 | data MenuSystem =
132 | MenuSystem
133 | { _menuMap :: Map MenuID Menu
134 | , _topMenu :: Menu
135 | }
136 | deriving (Read, Show, Eq, Ord, Generic, ToJSON, FromJSON)
137 |
138 | makeLenses ''MenuSystem
139 |
140 | data CompState =
141 | CSt
142 | { _salt :: ByteString
143 | -- ^ A pseudo-salt derived expensively from the overall name.
144 | , _menus :: Map MenuID Menu
145 | , _tags :: Set Tag
146 | }
147 | deriving (Read, Show, Eq, Ord, Generic)
148 |
149 | makeLenses ''CompState
150 |
151 | existDirectory :: FilePath -> IO ()
152 | existDirectory fp = E.catch (createDirectory fp) (\(_::E.SomeException) -> return ())
153 |
154 | -- | Load a menu from a file
155 | loadMenu :: FilePath -> IO Menu
156 | loadMenu fp = do
157 | either error return =<< JS.eitherDecodeFileStrict' fp
158 |
159 | -- | Loads a MenuSystem directory. The format is:
160 | -- FP:
161 | -- - root <- file containing root menu's ID
162 | -- - menus/ <- directory of one file per menu
163 | loadMenus :: IO MenuSystem
164 | loadMenus = do
165 | root <- either error return =<< JS.eitherDecodeFileStrict' ("graph" > "root")
166 | mns <- (fmap (("graph">"menu")>) <$> listDirectory ("graph" > "menu")) >>=
167 | (fmap (Map.fromList . map (\a -> (a ^.mid, a))) . mapM loadMenu)
168 | return . MenuSystem mns $ root^.rootMenu
169 |
170 | -- | Save a MenuSystem so it can be reloaded later for serving or use as a
171 | -- subcomponent of another MenuSystem.
172 | saveMenus :: MenuSystem -> IO ()
173 | saveMenus ms = do
174 | existDirectory "graph"
175 | JS.encodeFile ("graph">"root") . MenuRoot (ClientState mempty) $ ms^.topMenu
176 | storeSubMenus ms
177 |
178 | storeSubMenus :: MenuSystem -> IO ()
179 | storeSubMenus ms = do
180 | existDirectory $ "graph" > "menu"
181 | ifor_ (ms^.menuMap) $ \i m ->
182 | JS.encodeFile (("graph">"menu")>(T.unpack i)) m
183 |
184 | saveSubGraph :: Text -> MenuSystem -> IO ()
185 | saveSubGraph subname ms = do
186 | existDirectory "graph"
187 | storeSubMenus ms
188 | existDirectory $ "graph" > "subgraph"
189 | TIO.writeFile (("graph">"subgraph")>(T.unpack subname)) (ms^.topMenu.mid)
190 |
191 | refSubGraph :: Text -> IO Menu
192 | refSubGraph subname = do
193 | mnId <- TIO.readFile (("graph">"subgraph")>(T.unpack subname))
194 | loadMenu $ ("graph">"menu")>(T.unpack mnId)
195 |
--------------------------------------------------------------------------------
/src/Alto/Compile.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE OverloadedStrings
2 | , ScopedTypeVariables
3 | , TupleSections
4 | , FlexibleContexts
5 | #-}
6 | module Alto.Compile where
7 |
8 | import Alto.Menu
9 | import Control.Lens
10 | import qualified Control.Monad.Catch as E
11 | import Control.Monad.Writer
12 | import Control.Monad.State
13 | import qualified Crypto.Hash.SHA256 as SHA256
14 | import Crypto.Scrypt (ScryptParams)
15 | import qualified Crypto.Scrypt as Scrypt
16 | import qualified Data.ByteString.Base64.URL as B64
17 | import qualified Data.ByteString as BS
18 | import qualified Data.ByteString.Lazy as BSL
19 | import qualified Data.Map as Map
20 | import Data.Maybe (fromJust)
21 | import qualified Data.Set as Set
22 | import Data.Text (Text)
23 | import qualified Data.Text as T
24 | import qualified Data.Text.Encoding as TE
25 | import System.RandomString
26 |
27 | saltDerivingParams :: ScryptParams
28 | saltDerivingParams = Scrypt.defaultParams
29 |
30 | type MenuM a = StateT CompState IO a
31 | type EntryM a = WriterT [MenuEntry] (StateT CompState IO) a
32 |
33 | -- | Loads a subgraph if it exists, otherwise compiles it.
34 | subGraph :: Text -> MenuM Menu -> MenuM Menu
35 | subGraph sgName desc =
36 | E.catch (lift $ refSubGraph sgName) $ \(_::E.SomeException) -> do
37 | ms <- lift $ compileRoot sgName desc
38 | lift $ saveSubGraph sgName ms
39 | return . fromJust $ ms^.menuMap.at (ms^.topMenu.mid)
40 |
41 | -- | Compiles a MenuSystem given a name we produce a salt from.
42 | -- Any menu systems sharing tags must agree on the project name.
43 | compileRoot :: Text -> MenuM Menu -> IO MenuSystem
44 | compileRoot name desc = do
45 | let compSalt = B64.encode . Scrypt.getEncryptedPass . Scrypt.encryptPass
46 | saltDerivingParams (Scrypt.Salt $ TE.encodeUtf8 "Jektulv!OCod3gob6Glaj@") .
47 | Scrypt.Pass . TE.encodeUtf8 $ name
48 | (rm, (CSt _ mnmp _)) <- desc `runStateT` (CSt compSalt mempty mempty)
49 | return $ MenuSystem mnmp rm
50 |
51 | idBytes :: Int
52 | idBytes = 128 `div` 8
53 |
54 | -- | Generate a (hopefully) unique ID based off the name, pseudo-salted from the root name.
55 | -- The root derived pseudo salt is expensively generated to make guessing attacks
56 | -- fairly unreasonable. Truly though this is just to keep the honest honest.
57 | genTagID :: MonadState CompState m => Text -> m Tag
58 | genTagID nm = do
59 | ss <- use salt
60 | -- If our parts encode the same, we are the same.
61 | return . T.init . TE.decodeUtf8 . B64.encode . BS.take idBytes .
62 | SHA256.hashlazy .
63 | BSL.fromChunks $ [TE.encodeUtf8 nm, ss]
64 |
65 | genMenuID :: MenuM MenuID
66 | genMenuID = lift $ randomString (StringOpts Base58 idBytes)
67 |
68 | -- | Import a menu system for use in this menu system.
69 | -- Returns the root of said menu system.
70 | importMenuSystem :: MenuSystem -> MenuM Menu
71 | importMenuSystem ms = do
72 | menus <>= (ms ^. menuMap)
73 | return (ms ^. topMenu)
74 |
75 | runEntryM :: EntryM () -> MenuM [MenuEntry]
76 | runEntryM = execWriterT
77 |
78 | updateEntries :: Menu -> EntryM () -> MenuM ()
79 | updateEntries m entry =
80 | void $ updateEntries' m entry
81 |
82 | updateEntries' :: Menu -> EntryM () -> MenuM Menu
83 | updateEntries' m entry = do
84 | ents <- runEntryM entry
85 | let
86 | m' = m & entries <>~ ents
87 | updateMenu m'
88 | return m'
89 |
90 | menu' :: TagChange -> EntryM () -> MenuM Menu
91 | menu' exitTC ents = do
92 | es <- runEntryM ents
93 | cid <- genMenuID
94 | let mn = Menu cid exitTC es
95 | -- Make sure this ID isn't already in use.
96 | omns <- menus <<%= (Map.insert cid mn)
97 | when (cid `Map.member` omns) $
98 | error ("The menu "<>(show es)<>" was already in used!")
99 | return $ mn
100 |
101 | menu :: EntryM () -> MenuM Menu
102 | menu = menu' mempty
103 |
104 | updateMenu :: Menu -> MenuM ()
105 | updateMenu m =
106 | menus #%= (Map.insert (m ^. mid) m)
107 |
108 | uniqueTag :: MonadState CompState m => Text -> m Tag
109 | uniqueTag t = do
110 | tid <- genTagID t
111 | existed <- use $ tags.contains tid
112 | when existed $ error "Tag already existed!"
113 | return tid
114 |
115 | -- | Add an entry to the menu
116 | ent :: MenuEntry -> EntryM ()
117 | ent = tell . pure
118 |
119 | mnAction :: Menu -> EntryType
120 | mnAction m = SubMenu mempty (m^.mid) Nothing
121 |
122 | andLogic :: TagLogic -> TagLogic -> TagLogic
123 | andLogic nl Always = nl
124 | andLogic nl (TLAnd ol) = TLAnd $ nl:ol
125 | andLogic nl ol = TLAnd [nl, ol]
126 |
127 | -- | Require a tag to be set for a menu entry to be displayed.
128 | infixl 5 &+
129 | (&+) :: MenuEntry -> Tag -> MenuEntry
130 | (&+) e t = e & display %~ andLogic (TagSet t)
131 |
132 | -- | Require a tag to be unset for a menu entry to be displayed.
133 | infixl 5 &-
134 | (&-) :: MenuEntry -> Tag -> MenuEntry
135 | (&-) e t = e & display %~ andLogic (TagUnset t)
136 |
137 | -- | Requires a specific tag logic to be true.
138 | infixl 5 &=
139 | (&=) :: MenuEntry -> TagLogic -> MenuEntry
140 | (&=) e tl = e & display %~ andLogic tl
141 |
142 | -- Make a MenuEntry link to a submenu
143 | -- (|-$) :: MenuEntry -> MenuM Menu -> MenuM MenuEntry
144 |
145 | infixl 5 |->
146 | (|->) :: MenuEntry -> Menu -> MenuEntry
147 | (|->) e m = e & reaction .~ SubMenu (e^.reaction.onAction) (m ^. mid) Nothing
148 |
149 | infixl 5 |-=
150 | (|-=) :: MenuEntry -> Action -> MenuEntry
151 | (|-=) e a = e & reaction .~ Action (e^.reaction.onAction) (Just a)
152 |
153 | infixl 5 |-==
154 | (|-==) :: MenuEntry -> EntryType -> MenuEntry
155 | (|-==) e a = e & reaction .~ (a&onAction.~(e^.reaction.onAction))
156 |
157 | infixl 5 |-//
158 | (|-//) :: MenuEntry -> Text -> MenuEntry
159 | (|-//) e u = e |-= (Nav u)
160 |
161 | infixl 5 |-#
162 | (|-#) :: MenuEntry -> Text -> EmbedSize -> MenuEntry
163 | (|-#) e u es = e |-= (Embed u es)
164 |
165 | -- | Make a MenuEntry set a tag.
166 | infixl 5 |-+
167 | (|-+) :: MenuEntry -> Tag -> MenuEntry
168 | (|-+) e t = e & reaction.onAction.setTags <>~ (Map.singleton t "")
169 |
170 | -- | Make a MenuEntry sset a number of tags.
171 | infixl 5 |-+*
172 | (|-+*) :: MenuEntry -> [Tag] -> MenuEntry
173 | (|-+*) e t = e & reaction.onAction.setTags <>~ (Map.fromList . map (,"") $ t)
174 |
175 | -- | Make a MenuEntry set a tag.
176 | infixl 5 |-+=
177 | (|-+=) :: MenuEntry -> Tag -> Text -> MenuEntry
178 | (|-+=) e t v = e & reaction.onAction.setTags <>~ (Map.singleton t v)
179 |
180 | infixl 5 |-<>
181 | (|-<>) :: MenuEntry -> TagChange -> MenuEntry
182 | (|-<>) e tc = e & reaction.onAction <>~ tc
183 |
184 | -- | Make a MenuEntry unset a tag.
185 | infixl 5 |--
186 | (|--) :: MenuEntry -> Tag -> MenuEntry
187 | (|--) e t = e & reaction.onAction.unsetTags <>~ (Set.singleton t)
188 |
189 | -- | Make a MenuEntry unset a number of tags.
190 | infixl 5 |--*
191 | (|--*) :: MenuEntry -> [Tag] -> MenuEntry
192 | (|--*) e t = e & reaction.onAction.unsetTags <>~ (Set.fromList t)
193 |
194 | -- | Like |-> but generates where it links off the value of a tag.
195 | infixl 5 |=>
196 | (|=>) :: MenuEntry -> Text -> Tag -> MenuEntry
197 | (|=>) e mpre tg = e & reaction .~ SubMenu (e^.reaction.onAction) mpre (Just tg)
198 |
--------------------------------------------------------------------------------
/client/src/ui/menu.js:
--------------------------------------------------------------------------------
1 | import html from 'nanohtml'
2 | import morph from 'nanomorph'
3 | import style from 'dom-css'
4 |
5 | import indicateLoading from '../indicateLoading'
6 | import hoverMenuButton from './hoverMenuButton'
7 | import menuItem from './menuItem'
8 |
9 | const scrollHoverButtonSize = 20
10 |
11 | export const styles = css`
12 | @import "./colors.less";
13 |
14 | .menu {
15 | position: fixed;
16 | display: flex;
17 | max-width: 250px;
18 | font-family: sans-serif;
19 | font-size: 11.5pt;
20 | font-variant: none;
21 | text-align: left;
22 | background-color: @itemBackgroundColor;
23 | border-radius: 3px;
24 | box-shadow: 0 0 10px rgba(0, 0, 0, .45);
25 | transition: opacity .15s ease-out;
26 |
27 | & > ul {
28 | margin: 0;
29 | padding: 0;
30 | overflow: hidden;
31 | }
32 |
33 | &.scrolling > ul {
34 | margin: ${scrollHoverButtonSize}px 0;
35 | }
36 | }
37 |
38 | .scrollButton {
39 | display: flex;
40 | height: ${scrollHoverButtonSize}px;
41 | align-items: center;
42 | justify-content: center;
43 | background-color: @itemBackgroundColor;
44 |
45 | &:hover{
46 | background-color: @itemHighlightColor;
47 | }
48 |
49 |
50 | &.top {
51 | position: absolute;
52 | top: 0;
53 | left: 0;
54 | right: 0;
55 | border-bottom: 1px ridge rgba(0, 0, 0, .2);
56 | }
57 |
58 | &.bottom {
59 | position: absolute;
60 | bottom: 0;
61 | left: 0;
62 | right: 0;
63 | border-top: 1px groove rgba(0, 0, 0, .2);
64 | }
65 | }
66 | `
67 |
68 | export default function menu(props) {
69 | const {items, itemGen, onMenuSelect, onMenuEnter, onMenuLeave, attach, isScrolling} = props
70 | let itemEls
71 | let childMenu
72 | let hoverButtonEls
73 | let highlightedIdx
74 | let loadingIndicator
75 |
76 | const getParentEl = el => el.closest('.' + styles.menu)
77 |
78 | function renderItem(item) {
79 | const isHighlighted = item.idx === highlightedIdx
80 | return menuItem({
81 | item: item,
82 | showArrows: items.some(x => x.subMenuId),
83 | isHighlighted,
84 | isLoading: isHighlighted && loadingIndicator && loadingIndicator.isLoading,
85 | itemGen,
86 | onMouseEnter: handleItemEnter,
87 | onMouseLeave: handleItemLeave,
88 | onItemSelect: handleItemSelect,
89 | attach,
90 | })
91 | }
92 |
93 | function closeSubMenu() {
94 | if (!childMenu) {
95 | return
96 | }
97 | childMenu.closeMenu()
98 | childMenu = null
99 | }
100 |
101 | function handleItemEnter(item, itemEl) {
102 | updateHighlighted(item, itemEl)
103 | }
104 |
105 | function handleItemLeave() {
106 | updateHighlighted(null, null)
107 | }
108 |
109 | function handleItemSelect(item) {
110 | onMenuSelect(item.menuId, item.entryIdx)
111 | }
112 |
113 | function updateHighlighted(item, itemEl) {
114 | if (item && item.idx === highlightedIdx) {
115 | return
116 | }
117 |
118 | if (!item && childMenu) {
119 | return
120 | }
121 |
122 | if (loadingIndicator) {
123 | loadingIndicator.cancel()
124 | loadingIndicator = null
125 | }
126 |
127 | const oldHighlightedIdx = highlightedIdx
128 | highlightedIdx = item && item.idx
129 |
130 | if (oldHighlightedIdx != null) {
131 | morph(itemEls[oldHighlightedIdx], renderItem(items[oldHighlightedIdx]))
132 | }
133 |
134 | if (itemEl) {
135 | morph(itemEl, renderItem(item))
136 |
137 | closeSubMenu()
138 |
139 | const itemBox = itemEl.getBoundingClientRect()
140 | if (item.subMenuId) {
141 | loadingIndicator = indicateLoading(() => {
142 | morph(itemEl, renderItem(item))
143 | })
144 | onMenuEnter(item.menuId, item.entryIdx, item.subMenuId)
145 | childMenu = showMenu({
146 | id: item.subMenuId,
147 | itemGen,
148 | onMenuSelect,
149 | onMenuEnter,
150 | onMenuLeave,
151 | onLoad: loadingIndicator.finished,
152 | parentBox: itemBox,
153 | attach,
154 | })
155 | }
156 | }
157 | }
158 |
159 | function handleScrollWheel(ev) {
160 | closeSubMenu()
161 | handleItemLeave()
162 | ev.currentTarget.scrollTop += ev.deltaY
163 | ev.preventDefault()
164 | }
165 |
166 | function scrollUp(ev) {
167 | closeSubMenu()
168 | handleItemLeave()
169 | getParentEl(ev.target).querySelector('ul').scrollTop -= 6
170 | }
171 |
172 | function scrollDown(ev) {
173 | closeSubMenu()
174 | handleItemLeave()
175 | getParentEl(ev.target).querySelector('ul').scrollTop += 6
176 | }
177 |
178 | if (items) {
179 | itemEls = items.map(renderItem)
180 | }
181 |
182 | if (isScrolling) {
183 | hoverButtonEls = [
184 | hoverMenuButton({
185 | className: `${styles.scrollButton} ${styles.top}`,
186 | onTrigger: scrollUp,
187 | direction: 'up',
188 | }),
189 | hoverMenuButton({
190 | className: `${styles.scrollButton} ${styles.bottom}`,
191 | onTrigger: scrollDown,
192 | direction: 'down',
193 | }),
194 | ]
195 | }
196 |
197 | const classes = [
198 | styles.menu,
199 | isScrolling && styles.scrolling,
200 | ].filter(x => x)
201 |
202 | const el = html`
203 | {ev.preventDefault()}}
206 | >
207 |
210 | ${hoverButtonEls}
211 |
212 | `
213 | style(el, {
214 | opacity: items ? 1 : 0,
215 | })
216 |
217 | return {el, closeSubMenu}
218 | }
219 |
220 | function positionMenu(el, parentBox, attach) {
221 | style(el, {
222 | left: -9999,
223 | top: 0,
224 | })
225 | document.body.appendChild(el)
226 | const menuWidth = Math.ceil(el.getBoundingClientRect().width)
227 |
228 | // measure position and flip attach direction if necessary
229 | const {innerHeight, innerWidth} = window
230 | const childAttach = {...attach}
231 |
232 | const pos = {}
233 | const parentTop = Math.ceil(parentBox.top)
234 | const parentBottom = Math.ceil(parentBox.bottom)
235 | const parentLeft = Math.ceil(parentBox.left)
236 | const parentRight = Math.ceil(parentBox.right)
237 | const leftUnderHang = parentLeft - menuWidth
238 | const rightOverHang = parentRight + menuWidth - innerWidth
239 |
240 | // if there's x under/overhang swap to side with most space and fill remaining space.
241 | if (attach.x === 'left' && leftUnderHang < 0 && rightOverHang < -leftUnderHang) {
242 | childAttach.x = 'right'
243 | } else if (attach.x === 'right' && rightOverHang > 0 && -leftUnderHang < rightOverHang) {
244 | childAttach.x = 'left'
245 | }
246 | if (childAttach.x === 'left') {
247 | pos.left = Math.max(0, leftUnderHang)
248 | pos.width = Math.min(menuWidth, parentLeft - pos.left)
249 | } else if (childAttach.x === 'right') {
250 | pos.left = parentRight
251 | pos.width = Math.min(menuWidth, innerWidth - pos.left)
252 | }
253 |
254 | // width affects text wrapping which affects height, so we do this now.
255 | style(el, pos)
256 | const menuHeight = Math.ceil(el.getBoundingClientRect().height)
257 | document.body.removeChild(el)
258 |
259 | // y positioning is easier: when it hits the screen edge offset, possibly filling vertical space.
260 | if (attach.y === 'bottom') {
261 | pos.top = parentBottom - menuHeight
262 | if (pos.top < 0) {
263 | pos.top = 0
264 | childAttach.y = 'top'
265 | }
266 | } else if (attach.y === 'top') {
267 | pos.top = parentTop
268 | const overHang = parentTop + menuHeight - innerHeight
269 | if (overHang > 0) {
270 | pos.top = Math.max(0, pos.top - overHang)
271 | childAttach.y = 'bottom'
272 | }
273 | }
274 | pos.maxHeight = innerHeight - pos.top
275 |
276 | const isScrolling = menuHeight > pos.maxHeight
277 |
278 | return {pos, childAttach, isScrolling}
279 | }
280 |
281 | export function showMenu(props) {
282 | const {itemGen, onMenuSelect, onMenuEnter, onMenuLeave, onLoad, id, parentBox, attach} = props
283 |
284 | let menuObj
285 | let menuEl
286 |
287 | async function loadMenu() {
288 | const items = await itemGen(id)
289 |
290 | // render for size measurement
291 | let {el: sizingEl} = menu({
292 | items,
293 | itemGen,
294 | attach
295 | })
296 | const {pos, childAttach, isScrolling} = positionMenu(sizingEl, parentBox, attach)
297 |
298 | // render
299 | menuObj = menu({
300 | items,
301 | itemGen,
302 | onMenuSelect,
303 | onMenuEnter,
304 | onMenuLeave,
305 | attach: childAttach,
306 | isScrolling,
307 | })
308 | style(menuObj.el, pos)
309 | morph(menuEl, menuObj.el)
310 |
311 | if (onLoad) {
312 | onLoad()
313 | }
314 | }
315 |
316 | function closeMenu() {
317 | menuObj.closeSubMenu()
318 | document.body.removeChild(menuEl)
319 | onMenuLeave(id)
320 | }
321 |
322 | menuObj = menu({})
323 | menuEl = menuObj.el
324 | document.body.appendChild(menuEl)
325 |
326 | setTimeout(loadMenu, 0)
327 |
328 | return {el: menuEl, closeMenu}
329 | }
330 |
331 | export function attachMenuTo(props) {
332 | const {triggerEl, menuProps, id, itemGen, onMenuSelect, onMenuEnter, onMenuLeave} = props
333 | let menuObj
334 | let isTouching = false
335 | let openedMenu = false
336 | let longPressTimeout
337 |
338 | function closeMenu() {
339 | if (menuObj) {
340 | menuObj.closeMenu()
341 | menuObj = null
342 | }
343 | }
344 |
345 | function closeMenuIfOutside(ev) {
346 | if (menuObj && ev.target.closest('.' + styles.menu)) {
347 | return
348 | }
349 | closeMenu()
350 | }
351 |
352 | function handleMenuSelect(menuId, entryIdx) {
353 | closeMenu()
354 | onMenuSelect(menuId, entryIdx)
355 | }
356 |
357 | async function openMenu(pos) {
358 | closeMenu()
359 | menuObj = await showMenu({
360 | id,
361 | itemGen,
362 | onMenuSelect: handleMenuSelect,
363 | onMenuEnter,
364 | onMenuLeave,
365 | parentBox: {left: pos.x, right: pos.x, top: pos.y},
366 | attach: {x: 'right', y: 'top'},
367 | })
368 | }
369 |
370 | window.addEventListener('mousedown', closeMenuIfOutside)
371 |
372 | triggerEl.addEventListener('contextmenu', ev => {
373 | ev.preventDefault()
374 | if (!isTouching) {
375 | // prevent contextmenu from double-triggering on long press in Chrome.
376 | openMenu({x: ev.clientX, y: ev.clientY})
377 | }
378 | })
379 |
380 | // we have to implement our own long press detection because iOS Safari
381 | // doesn't trigger contextmenu on touch.
382 | triggerEl.addEventListener('touchstart', ev => {
383 | isTouching = true
384 | longPressTimeout = setTimeout(() => {
385 | openMenu({
386 | x: Math.floor(ev.touches[0].clientX),
387 | y: Math.floor(ev.touches[0].clientY),
388 | })
389 | openedMenu = true
390 | }, 250)
391 | })
392 |
393 | triggerEl.addEventListener('touchmove', () => {
394 | clearTimeout(longPressTimeout)
395 | })
396 |
397 | triggerEl.addEventListener('touchend', ev => {
398 | isTouching = false
399 | clearTimeout(longPressTimeout)
400 |
401 | // prevent mousedown event
402 | if (openedMenu && ev.cancelable) {
403 | ev.preventDefault()
404 | }
405 | openedMenu = false
406 | })
407 | }
408 |
--------------------------------------------------------------------------------