├── .editorconfig ├── .eslintrc ├── .gitignore ├── .vscode ├── extentions.json └── settings.json ├── README.md ├── docs └── demo.gif ├── package.json ├── src ├── components │ ├── Canvas.tsx │ ├── RectLayer.tsx │ ├── ResizeHandler.tsx │ ├── RotateHandler.tsx │ ├── context.ts │ └── hooks.ts ├── declaration.d.ts ├── domains │ └── Layer │ │ ├── model.ts │ │ └── reducer.ts ├── helloworld.html ├── helloworld.tsx ├── index.html ├── index.tsx └── lib │ ├── browser │ ├── devise.ts │ └── events.ts │ ├── draggable │ ├── MouseDraggable.ts │ ├── TouchDraggable.ts │ ├── index.ts │ └── types.ts │ ├── flux │ └── util.ts │ └── math │ └── geometry.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | 12 | [*.html] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | # CSS - https://developer.mozilla.org/ja/docs/Web/CSS 17 | # Sass(SCSS) - http://sass-lang.com/ 18 | # Stylus - https://learnboost.github.io/stylus/ 19 | # PostCSS - https://postcss.org/ 20 | [*.{css,scss,sass,styl,pcss}] 21 | indent_style = space 22 | indent_size = 2 23 | trim_trailing_whitespace = true 24 | 25 | # Handlebars.js - http://handlebarsjs.com/ 26 | [*.hbs] 27 | indent_style = space 28 | indent_size = 2 29 | 30 | # JavaScript - https://developer.mozilla.org/ja/docs/Web/JavaScript 31 | # TypeScript - https://www.typescriptlang.org/ 32 | # React - https://reactjs.org/ 33 | # Vue - https://vuejs.org/ 34 | [*.{js,ts,jsx,tsx,vue}] 35 | indent_style = space 36 | indent_size = 2 37 | 38 | # JSON - http://json.org/ 39 | # Composer - https://getcomposer.org/doc/04-schema.md 40 | [{*.json,composer.lock}] 41 | indent_style = space 42 | indent_size = 4 43 | 44 | # NPM - https://docs.npmjs.com/files/package.json 45 | # TypeScript - https://www.typescriptlang.org/ 46 | # Stylint - https://rosspatton.github.io/stylint/ 47 | # prettier - https://prettier.io/ 48 | # ESlint - https://eslint.org/ 49 | # VSCode Settings - https://code.visualstudio.com/docs/getstarted/settings 50 | [{package.json,tsconfig.json,.stylintrc,.prettierrc,.eslintrc,.vscode/settings.json}] 51 | indent_style = space 52 | indent_size = 2 53 | 54 | # PHP - http://php.net/ 55 | [*.php] 56 | indent_style = space 57 | indent_size = 4 58 | 59 | # Python - https://www.python.org/ 60 | [*.py] 61 | indent_style = space 62 | indent_size = 4 63 | 64 | # Ruby - https://www.ruby-lang.org/ 65 | # Rake - https://github.com/ruby/rake 66 | [{*.rb,*.rake,Rakefile}] 67 | indent_style = space 68 | indent_size = 2 69 | 70 | # Shell script (bash) - https://www.gnu.org/software/bash/manual/bash.html 71 | [*.sh] 72 | indent_style = space 73 | indent_size = 4 74 | 75 | # SQL (MySQL) - https://www.mysql.com/ 76 | [*.sql] 77 | indent_style = space 78 | indent_size = 2 79 | 80 | # Smarty 2 -http://www.smarty.net/docsv2/en/ 81 | [*.tpl] 82 | indent_style = space 83 | indent_size = 4 84 | 85 | # XHTML - http://www.w3.org/TR/xhtml1/ 86 | [*.xhtml] 87 | indent_style = space 88 | indent_size = 4 89 | 90 | # YAML - http://yaml.org/ 91 | [{*.yml,*.yaml}] 92 | indent_style = space 93 | indent_size = 2 94 | 95 | # p(ixi)v 96 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/eslint-recommended" 10 | ], 11 | "globals": { 12 | "Atomics": "readonly", 13 | "SharedArrayBuffer": "readonly" 14 | }, 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaFeatures": { 18 | "jsx": true 19 | }, 20 | "ecmaVersion": 2018, 21 | "sourceType": "module" 22 | }, 23 | "plugins": ["react", "react-hooks", "@typescript-eslint"], 24 | "settings": { 25 | "react": { 26 | "version": "detect" 27 | } 28 | }, 29 | "rules": { 30 | "no-unused-vars": 0, 31 | "@typescript-eslint/no-unused-vars": [ 32 | 2, 33 | { 34 | "argsIgnorePattern": "^_" 35 | } 36 | ], 37 | "react/jsx-uses-vars": 2, 38 | 39 | "no-redeclare": 1, 40 | 41 | "no-console": 2, 42 | "no-debugger": 2, 43 | "no-var": 2, 44 | "react/display-name": 0, 45 | "react/prop-types": 0, 46 | "react/no-this-in-sfc": 2, 47 | "react/self-closing-comp": 0, 48 | "react-hooks/rules-of-hooks": 2, 49 | "react-hooks/exhaustive-deps": 2 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache 4 | yarn-error.log 5 | -------------------------------------------------------------------------------- /.vscode/extentions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 『入門 GUI』 第 2 章 「バウンディングボックスことはじめ」サンプルコード 2 | 3 | 『入門 GUI(以下、GUI 本)』の第 2 章のサンプルコードです 4 | 5 | ![demo](docs/demo.gif) 6 | 7 | ### 必要なもの 8 | 9 | - yarn 10 | 11 | ### ファイル 12 | 13 | - `src/helloworld.html`: 「2.2 レイヤーについて」で説明している、一番最初の状態の html です。`yarn parcel src/helloworld.html -p 3000` で「図 2.3 Hello world! SVG」と同じ状態になることが確認できます。 14 | - `src/index.html`: 全体の完成像です 15 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsubal/guibook/152811f79d91e4579d8f8ab0bbed0319467f78bb/docs/demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guibook", 3 | "version": "1.0.0", 4 | "description": "『入門 GUI』 第2章 「バウンディングボックスことはじめ」サンプルコード", 5 | "repository": "git@github.com:fsubal/guibook.git", 6 | "author": "fsubal ", 7 | "license": "MIT", 8 | "scripts": { 9 | "lint": "eslint --ext .js,.jsx,.ts,.tsx src" 10 | }, 11 | "devDependencies": { 12 | "@types/lodash-es": "^4.17.3", 13 | "@types/react": "^16.9.34", 14 | "@types/react-dom": "^16.9.7", 15 | "@typescript-eslint/eslint-plugin": "^2.30.0", 16 | "@typescript-eslint/parser": "^2.30.0", 17 | "eslint": "^6.8.0", 18 | "eslint-plugin-react": "^7.19.0", 19 | "eslint-plugin-react-hooks": "^4.0.2", 20 | "parcel-bundler": "^1.12.4", 21 | "prettier": "^2.0.5", 22 | "typescript": "^3.8.3" 23 | }, 24 | "dependencies": { 25 | "immer": "^9.0.6", 26 | "lodash-es": "^4.17.15", 27 | "react": "^16.13.1", 28 | "react-dom": "^16.13.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useCallback } from "react"; 2 | import reducer, { initialState, LayerAction } from "../domains/Layer/reducer"; 3 | import { RectLayer } from "./RectLayer"; 4 | import CanvasContext from "./context"; 5 | 6 | export function Canvas() { 7 | const [state, dispatch] = useReducer(reducer, initialState); 8 | 9 | const onDragStart = useCallback((x: Pixel, y: Pixel, e: Event) => { 10 | const layerId = Number((e.currentTarget as HTMLElement).dataset.layerId); 11 | 12 | dispatch(LayerAction.moveStarted(layerId, x, y)); 13 | }, []); 14 | 15 | const onMove = useCallback((dx: Pixel, dy: Pixel) => { 16 | dispatch(LayerAction.moved(dx, dy)); 17 | }, []); 18 | 19 | const onDragEnd = useCallback((e: Event) => { 20 | e.stopPropagation(); 21 | dispatch(LayerAction.moveEnded()); 22 | }, []); 23 | 24 | return ( 25 | 26 | 31 | {state.layers.map((layer) => ( 32 | 39 | ))} 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/RectLayer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useCallback, useRef, useEffect } from "react"; 2 | import { Layer } from "../domains/Layer/model"; 3 | import { useDrag } from "./hooks"; 4 | import { ResizeHandler } from "./ResizeHandler"; 5 | import { RotateHandler } from "./RotateHandler"; 6 | import { LayerAction } from "../domains/Layer/reducer"; 7 | import { isTouchDevice } from "../lib/browser/devise"; 8 | import CanvasContext from "./context"; 9 | 10 | interface Props { 11 | src: Layer; 12 | onMove(x: Pixel, y: Pixel): void; 13 | onDragStart(x: Pixel, y: Pixel, e: Event): void; 14 | onDragEnd(e: Event): void; 15 | } 16 | 17 | export function RectLayer({ src, onMove, onDragStart, onDragEnd }: Props) { 18 | const [, dispatch] = useContext(CanvasContext); 19 | 20 | const onResize = useCallback( 21 | (_dx: Pixel, _dy: Pixel, x: Pixel, y: Pixel) => { 22 | dispatch(LayerAction.resized(src.id, x, y)); 23 | }, 24 | [dispatch, src.id] 25 | ); 26 | 27 | const onRotate = useCallback( 28 | (_dx: Pixel, _dy: Pixel, x: Pixel, y: Pixel) => { 29 | dispatch(LayerAction.rotated(src.id, x, y)); 30 | }, 31 | [dispatch, src.id] 32 | ); 33 | 34 | const ref = useDrag(isTouchDevice, { 35 | onMove, 36 | onDragStart, 37 | onDragEnd, 38 | }); 39 | 40 | return ( 41 | 51 | 52 | 53 | 54 | 61 | 62 | 68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/ResizeHandler.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDrag } from "./hooks"; 3 | import { isTouchDevice } from "../lib/browser/devise"; 4 | import { Layer } from "../domains/Layer/model"; 5 | 6 | interface Props { 7 | layer: Layer; 8 | parentSize: [Pixel, Pixel]; 9 | onMove(dx: Pixel, dy: Pixel, x: Pixel, y: Pixel): void; 10 | onDragStart(x: Pixel, y: Pixel, e: Event): void; 11 | onDragEnd(e: Event): void; 12 | } 13 | 14 | const HANDLE_SIZE = 10 as Pixel; 15 | 16 | /** 17 | * 実際のリサイズハンドラよりもどのくらい当たり判定を大きくするか 18 | */ 19 | const TOLERANCE = 4 as Pixel; 20 | 21 | export function ResizeHandler({ 22 | layer, 23 | parentSize, 24 | onMove, 25 | onDragStart, 26 | onDragEnd, 27 | }: Props) { 28 | const ref = useDrag(isTouchDevice, { 29 | onMove, 30 | onDragStart, 31 | onDragEnd, 32 | }); 33 | 34 | const [width, height] = parentSize; 35 | const x = width - HANDLE_SIZE / 2; 36 | const y = height - HANDLE_SIZE / 2; 37 | 38 | return ( 39 | 40 | 49 | {/** 上に透明な当たり判定を大きめにかぶせる */} 50 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/RotateHandler.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDrag } from "./hooks"; 3 | import { Layer } from "../domains/Layer/model"; 4 | import { isTouchDevice } from "../lib/browser/devise"; 5 | 6 | interface Props { 7 | layer: Layer; 8 | onMove(dx: Pixel, dy: Pixel, x: Pixel, y: Pixel): void; 9 | onDragStart(x: Pixel, y: Pixel, e: Event): void; 10 | onDragEnd(e: Event): void; 11 | } 12 | 13 | const RADIUS = 6 as Pixel; 14 | 15 | export function RotateHandler({ 16 | layer, 17 | onMove, 18 | onDragStart, 19 | onDragEnd, 20 | }: Props) { 21 | const ref = useDrag(isTouchDevice, { 22 | onMove, 23 | onDragStart, 24 | onDragEnd, 25 | }); 26 | 27 | const cx = layer.width + RADIUS * 1.5; 28 | const cy = layer.height / 2 - RADIUS / 2; 29 | 30 | return ( 31 | 32 | 40 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/context.ts: -------------------------------------------------------------------------------- 1 | import React, { useReducer } from "react"; 2 | import { initialState } from "../domains/Layer/reducer"; 3 | 4 | const CanvasContext = React.createContext>([ 5 | initialState, 6 | (..._args: any) => { 7 | throw new Error("not initialized"); 8 | }, 9 | ]); 10 | 11 | export default CanvasContext; 12 | -------------------------------------------------------------------------------- /src/components/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react"; 2 | import makeDraggable from "../lib/draggable"; 3 | import { Handlers } from "../lib/draggable/types"; 4 | 5 | export function useDrag( 6 | isTouchDevice: boolean, 7 | { onMove, onDragStart, onDragEnd }: Handlers 8 | ) { 9 | const ref = useRef(null); 10 | 11 | useEffect(() => { 12 | const draggable = makeDraggable(ref.current!, isTouchDevice, { 13 | onMove, 14 | onDragStart, 15 | onDragEnd, 16 | }); 17 | 18 | return () => { 19 | draggable.destroy(); 20 | }; 21 | }, [isTouchDevice, onDragEnd, onDragStart, onMove]); 22 | 23 | return ref; 24 | } 25 | -------------------------------------------------------------------------------- /src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | type Pixel = number; 2 | type Radian = number; 3 | type Degree = number; 4 | -------------------------------------------------------------------------------- /src/domains/Layer/model.ts: -------------------------------------------------------------------------------- 1 | import { degree2radian } from "../../lib/math/geometry"; 2 | 3 | export interface Layer { 4 | id: number; 5 | width: Pixel; 6 | height: Pixel; 7 | positionX: Pixel; 8 | positionY: Pixel; 9 | rotate: Degree; 10 | } 11 | 12 | export type Transform = Pick< 13 | Layer, 14 | "width" | "height" | "positionX" | "positionY" | "rotate" 15 | >; 16 | 17 | export function getAbsoluteCenter(layer: Layer): [Pixel, Pixel] { 18 | return [ 19 | layer.positionX + layer.width / 2, 20 | layer.positionY + layer.height / 2, 21 | ]; 22 | } 23 | 24 | export function rotateVector( 25 | vector: [Pixel, Pixel], 26 | rotate: Degree 27 | ): [Pixel, Pixel] { 28 | const theta = degree2radian(rotate); 29 | 30 | const [x, y] = vector; 31 | const cos = Math.cos(theta); 32 | const sin = Math.sin(theta); 33 | 34 | return [x * cos - y * sin, x * sin + y * cos]; 35 | } 36 | 37 | export function rotateByCenter(degree: Degree, [cx, cy]: [Pixel, Pixel]) { 38 | return function apply([x, y]: [Pixel, Pixel]) { 39 | const [rotatedX, rotatedY] = rotateVector([x - cx, y - cy], degree); 40 | 41 | return [rotatedX + cx, rotatedY + cy]; 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/domains/Layer/reducer.ts: -------------------------------------------------------------------------------- 1 | import produce from "immer"; 2 | import pick from "lodash-es/pick"; 3 | import { Layer, Transform, getAbsoluteCenter, rotateByCenter } from "./model"; 4 | import { action, KnownActions, unreduceable } from "../../lib/flux/util"; 5 | import { radian2degree } from "../../lib/math/geometry"; 6 | 7 | interface State { 8 | layers: Layer[]; 9 | initialTransforms: Record; 10 | initialMousePosition: [Pixel, Pixel]; 11 | layerCenter: Record; 12 | } 13 | 14 | export const initialState: State = { 15 | layers: [ 16 | { 17 | id: 1, 18 | width: 200, 19 | height: 100, 20 | positionX: 0, 21 | positionY: 0, 22 | rotate: 0, 23 | }, 24 | ], 25 | initialTransforms: {}, 26 | initialMousePosition: [0, 0], 27 | layerCenter: {}, 28 | }; 29 | 30 | export const LayerAction = { 31 | moveStarted: (id: Layer["id"], x: Pixel, y: Pixel) => 32 | action("layer/moveStarted", { id, x, y }), 33 | moved: (dx: Pixel, dy: Pixel) => action("layer/moved", { dx, dy }), 34 | moveEnded: () => action("layer/moveEnded", {}), 35 | resized: (id: Layer["id"], x: Pixel, y: Pixel) => 36 | action("layer/resized", { id, x, y }), 37 | rotated: (id: Layer["id"], x: Pixel, y: Pixel) => 38 | action("layer/rotated", { id, x, y }), 39 | }; 40 | 41 | const reducer = ( 42 | currentState: State, 43 | action: KnownActions 44 | ) => 45 | produce(currentState, (state: State) => { 46 | switch (action.type) { 47 | case "layer/moveStarted": { 48 | const { id, x, y } = action.payload; 49 | const layer = state.layers.find((layer) => layer.id === id); 50 | if (!layer) { 51 | return; 52 | } 53 | 54 | // 変形開始時のレイヤーの状態を覚えておく 55 | state.initialTransforms[layer.id] = pick(layer, [ 56 | "width", 57 | "height", 58 | "positionX", 59 | "positionY", 60 | "rotate", 61 | ]); 62 | 63 | // 回転のため、レイヤーの中心座標を覚えておく 64 | state.layerCenter[layer.id] = getAbsoluteCenter(layer); 65 | 66 | // 変形開始時のマウスの座標も覚えておく 67 | state.initialMousePosition = [x, y]; 68 | break; 69 | } 70 | 71 | case "layer/moved": { 72 | const { dx, dy } = action.payload; 73 | state.layers.forEach((layer) => { 74 | const transform = state.initialTransforms[layer.id]; 75 | if (!transform) { 76 | return; 77 | } 78 | 79 | const { positionX, positionY } = transform; 80 | layer.positionX = positionX + dx; 81 | layer.positionY = positionY + dy; 82 | }); 83 | break; 84 | } 85 | 86 | case "layer/moveEnded": { 87 | state.initialTransforms = {}; 88 | state.layerCenter = {}; 89 | state.initialMousePosition = [0, 0]; 90 | break; 91 | } 92 | 93 | case "layer/resized": { 94 | const { id, x, y } = action.payload; 95 | const layer = state.layers.find((layer) => layer.id === id); 96 | if (!layer) { 97 | return; 98 | } 99 | 100 | const transform = state.initialTransforms[layer.id]; 101 | if (!transform) { 102 | return; 103 | } 104 | 105 | const layerCenter = state.layerCenter[layer.id]; 106 | if (!layerCenter) { 107 | return; 108 | } 109 | 110 | const { width, height } = transform; 111 | 112 | const [cx, cy] = layerCenter; 113 | const [rotatedCursorX, rotatedCursorY] = rotateByCenter(-layer.rotate, [ 114 | cx, 115 | cy, 116 | ])([x, y]); 117 | 118 | const [endX, endY] = [ 119 | transform.width + transform.positionX, 120 | transform.height + transform.positionY, 121 | ]; 122 | 123 | // レイヤーの中心を原点に拡大する場合、マウスの移動分に対して半分しか大きくならないように見えてしまうので、差分を2倍すると良い 124 | const nextWidth = width + (rotatedCursorX - endX) * 2; 125 | const nextHeight = height + (rotatedCursorY - endY) * 2; 126 | 127 | layer.width = nextWidth; 128 | layer.height = nextHeight; 129 | layer.positionX = cx - nextWidth / 2; 130 | layer.positionY = cy - nextHeight / 2; 131 | break; 132 | } 133 | 134 | case "layer/rotated": { 135 | const { id, x, y } = action.payload; 136 | const layer = state.layers.find((layer) => layer.id === id); 137 | if (!layer) { 138 | return; 139 | } 140 | 141 | const [cx, cy] = state.layerCenter[layer.id]; 142 | 143 | /** θ の隣辺の長さ( x 方向) */ 144 | const vx = x - cx; 145 | /** θ の対辺の長さ( y 方向) */ 146 | const vy = y - cy; 147 | /** θ: 回転角(ラジアン) */ 148 | const nextTheta = Math.atan2(vy, vx); 149 | 150 | layer.rotate = radian2degree(nextTheta); 151 | break; 152 | } 153 | 154 | default: { 155 | unreduceable(action); 156 | } 157 | } 158 | }); 159 | 160 | export default reducer; 161 | -------------------------------------------------------------------------------- /src/helloworld.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 『入門 GUI』 第2章 「バウンディングボックスことはじめ」サンプルコード 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/helloworld.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | 4 | document.addEventListener("DOMContentLoaded", () => { 5 | render( 6 | 7 | 8 | , 9 | document.querySelector("#root")! 10 | ); 11 | }); 12 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 『入門 GUI』 第2章 「バウンディングボックスことはじめ」サンプルコード 8 | 9 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import { Canvas } from "./components/Canvas"; 4 | 5 | document.addEventListener("DOMContentLoaded", () => { 6 | render(, document.querySelector("#root")!); 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/browser/devise.ts: -------------------------------------------------------------------------------- 1 | export const isTouchDevice = "ontouchstart" in window; 2 | -------------------------------------------------------------------------------- /src/lib/browser/events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Second argument for Element#addEventListener 3 | */ 4 | export const passive = { passive: true }; 5 | -------------------------------------------------------------------------------- /src/lib/draggable/MouseDraggable.ts: -------------------------------------------------------------------------------- 1 | import { Draggable, Handlers } from "./types"; 2 | import { passive } from "../browser/events"; 3 | 4 | export class MouseDraggable implements Draggable { 5 | private initialClick?: { x: Pixel; y: Pixel }; 6 | 7 | constructor(private element: E, private handlers: Handlers) { 8 | this.element.addEventListener("mousedown", this._onClickStart, passive); 9 | } 10 | 11 | destroy() { 12 | this.element.removeEventListener("mousedown", this._onClickStart); 13 | } 14 | 15 | private _onClickStart = (e: MouseEvent) => { 16 | e.stopPropagation(); 17 | 18 | // 通常ありえない 19 | if (!e.currentTarget || !e.target) return; 20 | 21 | // document に対してリスナを登録しているのは、 22 | // ドラッグ中に対象の要素とマウスカーソルが重なっていない状態になって 23 | // mousemove, mouseup イベントが発生しなくなることによる不具合を防ぐため 24 | document.addEventListener("mousemove", this._onClickMove, passive); 25 | document.addEventListener("mouseup", this._onClickEnd, passive); 26 | 27 | const x = e.clientX as Pixel; 28 | const y = e.clientY as Pixel; 29 | 30 | this.initialClick = { x, y }; 31 | 32 | this.handlers.onDragStart(x, y, e); 33 | }; 34 | 35 | private _onClickMove = (e: MouseEvent) => { 36 | e.stopPropagation(); 37 | 38 | // 通常ありえない 39 | if (!e.currentTarget || !e.target) { 40 | return; 41 | } 42 | 43 | if (this.initialClick === undefined) { 44 | return; 45 | } 46 | 47 | const { x, y } = this.initialClick; 48 | const { clientX, clientY } = e; 49 | 50 | this.handlers.onMove(clientX - x, clientY - y, clientX, clientY, e); 51 | }; 52 | 53 | private _onClickEnd = (e: MouseEvent) => { 54 | e.stopPropagation(); 55 | 56 | document.removeEventListener("mousemove", this._onClickMove); 57 | document.removeEventListener("mouseup", this._onClickEnd); 58 | 59 | this.handlers.onDragEnd(e); 60 | 61 | this.initialClick = undefined; 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/draggable/TouchDraggable.ts: -------------------------------------------------------------------------------- 1 | import { Draggable, Handlers } from "./types"; 2 | import { passive } from "../browser/events"; 3 | 4 | export class TouchDraggable implements Draggable { 5 | private initialTouch?: { x: Pixel; y: Pixel }; 6 | 7 | constructor(private element: E, private handlers: Handlers) { 8 | this.element.addEventListener("touchstart", this._onTouchStart, passive); 9 | this.element.addEventListener("touchmove", this._onTouchMove, passive); 10 | this.element.addEventListener("touchend", this._onTouchEnd, passive); 11 | } 12 | 13 | destroy() { 14 | this.element.removeEventListener("touchstart", this._onTouchStart); 15 | this.element.removeEventListener("touchmove", this._onTouchMove); 16 | this.element.removeEventListener("touchend", this._onTouchEnd); 17 | } 18 | 19 | private _onTouchStart = (e: TouchEvent) => { 20 | e.stopPropagation(); 21 | 22 | // 通常ありえない 23 | if (!e.currentTarget || !e.target) { 24 | return; 25 | } 26 | 27 | // ピンチズーム とかで誤動作させない 28 | if (e.changedTouches.length !== 1) { 29 | return; 30 | } 31 | 32 | const touch = e.changedTouches[0]; 33 | const x = touch.clientX; 34 | const y = touch.clientY; 35 | 36 | this.initialTouch = { x, y }; 37 | this.handlers.onDragStart(x, y, e); 38 | }; 39 | 40 | private _onTouchMove = (e: TouchEvent) => { 41 | e.stopPropagation(); 42 | 43 | // 通常ありえない 44 | if (!e.currentTarget || !e.target) { 45 | return; 46 | } 47 | 48 | // ピンチズーム とかで誤動作させない 49 | if (e.changedTouches.length !== 1) { 50 | return; 51 | } 52 | 53 | if (this.initialTouch === undefined) { 54 | return; 55 | } 56 | 57 | const { x, y } = this.initialTouch; 58 | const { clientX, clientY } = e.changedTouches[0]; 59 | 60 | this.handlers.onMove(clientX - x, clientY - y, clientX, clientY, e); 61 | }; 62 | 63 | private _onTouchEnd = (e: TouchEvent) => { 64 | e.stopPropagation(); 65 | this.handlers.onDragEnd(e); 66 | this.initialTouch = undefined; 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/draggable/index.ts: -------------------------------------------------------------------------------- 1 | import { TouchDraggable } from "./TouchDraggable"; 2 | import { MouseDraggable } from "./MouseDraggable"; 3 | import { Handlers } from "./types"; 4 | 5 | export default function makeDraggable( 6 | el: E, 7 | isTouchDevice: boolean, 8 | { onMove, onDragStart, onDragEnd }: Handlers 9 | ) { 10 | if (isTouchDevice) { 11 | return new TouchDraggable(el, { onMove, onDragStart, onDragEnd }); 12 | } else { 13 | return new MouseDraggable(el, { onMove, onDragStart, onDragEnd }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/draggable/types.ts: -------------------------------------------------------------------------------- 1 | export interface Draggable { 2 | destroy(): void; 3 | } 4 | 5 | export interface Handlers { 6 | onMove(dx: Pixel, dy: Pixel, x: Pixel, y: Pixel, e: Event): void; 7 | onDragStart(x: Pixel, y: Pixel, e: Event): void; 8 | onDragEnd(e: Event): void; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/flux/util.ts: -------------------------------------------------------------------------------- 1 | export function action(type: T, payload: P) { 2 | return { 3 | type, 4 | payload, 5 | }; 6 | } 7 | 8 | export type KnownActions< 9 | A extends Record any> 10 | > = ReturnType; 11 | 12 | // reducer の網羅性検証を型安全に行うためのユーティリティ 13 | export const unreduceable = (unknownAction: never) => void unknownAction; 14 | -------------------------------------------------------------------------------- /src/lib/math/geometry.ts: -------------------------------------------------------------------------------- 1 | export function degree2radian(degree: Degree) { 2 | return (degree * Math.PI) / 180; 3 | } 4 | 5 | export function radian2degree(radian: Radian) { 6 | return (radian / Math.PI) * 180; 7 | } 8 | 9 | export function distanceBetween( 10 | [x1, y1]: [Pixel, Pixel], 11 | [x2, y2]: [Pixel, Pixel] 12 | ) { 13 | const [width, height] = [x2 - x1, y2 - y1]; 14 | 15 | return Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)); 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "lib": ["dom", "es2019"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true 10 | } 11 | } 12 | --------------------------------------------------------------------------------