├── .gitignore ├── vite.config.ts ├── src ├── main.tsx ├── hooks │ ├── useCssColor.ts │ ├── useSource.ts │ ├── useKeyboardHandler.ts │ ├── useContainer.tsx │ ├── useSettings.tsx │ ├── useCode.ts │ ├── useTextSelectionHandler.ts │ ├── useExport.ts │ └── useArrowDrawing.tsx ├── components │ ├── Footer.tsx │ ├── ColorPicker.tsx │ ├── MarkerRect.tsx │ ├── Popover.tsx │ ├── Export.tsx │ ├── Code.tsx │ ├── SelectionPopover.tsx │ ├── CodeAnnotations.tsx │ ├── ArrowLine.tsx │ └── Controls.tsx ├── App.tsx ├── source.ts ├── types.ts ├── colors.ts ├── store.ts ├── util.ts ├── github.ts ├── undoable.ts ├── pages │ ├── AnnotationPage.tsx │ └── SourceSelectionPage.tsx ├── geometry.ts ├── reducer.ts └── index.scss ├── netlify.toml ├── README.md ├── index.html ├── CODE_OF_CONDUCT.md ├── tsconfig.json ├── patches └── html2canvas+1.2.1.patch ├── package.json └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | .netlify 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import reactRefresh from '@vitejs/plugin-react-refresh' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [reactRefresh()] 7 | }) 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | import './index.scss' 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root'), 11 | ) 12 | -------------------------------------------------------------------------------- /src/hooks/useCssColor.ts: -------------------------------------------------------------------------------- 1 | import { Color, cssColorFromColor } from '../colors' 2 | import { useSettings } from './useSettings' 3 | 4 | export default function useCssColor(color: Color) { 5 | const { annotationBrightness } = useSettings() 6 | return cssColorFromColor(color, annotationBrightness) 7 | } 8 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = "dist" 4 | 5 | [[redirects]] 6 | from = "https://annotate.code-reading.org/*" 7 | to = "https://annotate.codereading.club/:splat" 8 | status = 301 9 | force = true 10 | 11 | #[dev] 12 | # command = "npm run dev" 13 | # port = 3000 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Annotation Tool 2 | 3 | A tool to annotate code for [code reading clubs](https://codereading.club). 4 | 5 | [Check it out](https://annotate.codereading.club)! 6 | 7 | ## Running 8 | 9 | ```shell 10 | npm install 11 | npm run dev 12 | ``` 13 | 14 | ## Deploying 15 | 16 | `npm run build` will build the site in `dist/`. It's currently deployed on 17 | Netlify. 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Code Annotation Tool 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export function Footer() { 4 | return ( 5 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We strive to be inclusive and value all people equally. Our values and 4 | guidelines can be found in our [Code of Conduct][coc]. 5 | 6 | If you experience any behaviours or atmosphere that feels contrary to these 7 | values, please let us know at [hello@codereading.club][email]. We want everyone 8 | to feel safe, equal and welcome. 9 | 10 | [coc]: https://codereading.club/conduct 11 | [email]: mailto:hello@codereading.club 12 | -------------------------------------------------------------------------------- /src/hooks/useSource.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { useParams } from 'react-router-dom' 3 | import { parseHash, Source } from '../source' 4 | 5 | export default function useSource(): Source { 6 | const { hash } = useParams<{ hash?: string }>() 7 | if (!hash) { 8 | throw new Error(`url is missing a hash param. Can't determine code source`) 9 | } 10 | const source = useMemo(() => parseHash(hash), [hash]) 11 | if (!source) { 12 | throw new Error(`Can't parse source from the url hash param`) 13 | } 14 | return source 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "types": ["vite/client"], 6 | "allowJs": false, 7 | "checkJs": false, 8 | "skipLibCheck": false, 9 | "esModuleInterop": false, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "ESNext", 14 | "moduleResolution": "Node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react" 19 | }, 20 | "include": ["./src"] 21 | } 22 | -------------------------------------------------------------------------------- /patches/html2canvas+1.2.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/html2canvas/dist/html2canvas.esm.js b/node_modules/html2canvas/dist/html2canvas.esm.js 2 | index dfa7f75..fce2e79 100644 3 | --- a/node_modules/html2canvas/dist/html2canvas.esm.js 4 | +++ b/node_modules/html2canvas/dist/html2canvas.esm.js 5 | @@ -6208,6 +6208,7 @@ var CanvasRenderer = /** @class */ (function (_super) { 6 | this.path(effect.path); 7 | this.ctx.clip(); 8 | } 9 | + this.ctx.globalCompositeOperation = 'multiply' 10 | this._activeEffects.push(effect); 11 | }; 12 | CanvasRenderer.prototype.popEffect = function () { 13 | -------------------------------------------------------------------------------- /src/hooks/useKeyboardHandler.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | // with a little help from https://stackoverflow.com/a/57926311 4 | export default function useKeyboardHandler( 5 | handler: (event: KeyboardEvent) => void, 6 | ) { 7 | const handlerRef = React.useRef(handler) 8 | 9 | React.useEffect(() => { 10 | handlerRef.current = handler 11 | }, [handler]) 12 | 13 | React.useEffect(() => { 14 | const eventListener = (event: KeyboardEvent) => handlerRef.current(event) 15 | document.addEventListener('keydown', eventListener) 16 | 17 | return () => { 18 | document.removeEventListener('keydown', eventListener) 19 | } 20 | }, []) 21 | } 22 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { HashRouter as Router, Route, Switch } from 'react-router-dom' 3 | import AnnotationPage from './pages/AnnotationPage' 4 | import SourceSelectionPage from './pages/SourceSelectionPage' 5 | import { SettingsProvider } from './hooks/useSettings' 6 | 7 | export default function App() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/source.ts: -------------------------------------------------------------------------------- 1 | import LZString from 'lz-string' 2 | import { File, parsePath, pathForFile } from './github' 3 | 4 | export type Source = { 5 | type: 'githubPermalink' 6 | file: File 7 | } 8 | 9 | export function parseHash(hash: string): Source | null { 10 | const githubPath = LZString.decompressFromEncodedURIComponent(hash) 11 | if (!githubPath) { 12 | return null 13 | } 14 | 15 | const parseResult = parsePath(githubPath) 16 | if (parseResult.type !== 'success') { 17 | return null 18 | } 19 | 20 | return { type: 'githubPermalink', file: parseResult.file } 21 | } 22 | 23 | export function sourceHash(source: Source): string { 24 | return LZString.compressToEncodedURIComponent(pathForFile(source.file)) 25 | } 26 | 27 | export function localStorageKey(source: Source): string { 28 | return `github:${pathForFile(source.file)}` 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-annotation-tool", 3 | "license": "MIT", 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "serve": "vite preview", 9 | "postinstall": "patch-package" 10 | }, 11 | "dependencies": { 12 | "@reduxjs/toolkit": "^1.6.0", 13 | "html2canvas": "^1.2.1", 14 | "lz-string": "^1.4.4", 15 | "material-colors-ts": "^1.0.4", 16 | "patch-package": "^6.4.7", 17 | "react": "^17.0.0", 18 | "react-dom": "^17.0.0", 19 | "react-redux": "^7.2.4", 20 | "react-router-dom": "^5.2.0", 21 | "redux-persist": "^6.0.0", 22 | "uuid": "^8.3.2" 23 | }, 24 | "devDependencies": { 25 | "@types/lz-string": "^1.3.34", 26 | "@types/react": "^17.0.0", 27 | "@types/react-dom": "^17.0.0", 28 | "@types/react-redux": "^7.1.18", 29 | "@types/react-router-dom": "^5.1.8", 30 | "@types/uuid": "^8.3.1", 31 | "@vitejs/plugin-react-refresh": "^1.1.0", 32 | "sass": "^1.36.0", 33 | "typescript": "^4.1.2", 34 | "vite": "^2.0.0-beta.70" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Color } from '../colors' 3 | import useCssColor from '../hooks/useCssColor' 4 | 5 | type Props = { 6 | colors: Color[] 7 | onSelect: (color: Color) => void 8 | selectedColor?: Color 9 | } 10 | 11 | export default function ColorPicker({ 12 | colors, 13 | onSelect, 14 | selectedColor, 15 | }: Props) { 16 | return ( 17 |
18 | {colors.map((color) => ( 19 | onSelect(color)} 22 | selected={selectedColor === color} 23 | key={color} 24 | /> 25 | ))} 26 |
27 | ) 28 | } 29 | 30 | function ColorButton({ 31 | color, 32 | onClick, 33 | selected, 34 | }: { 35 | color: Color 36 | onClick: () => void 37 | selected: boolean 38 | }) { 39 | const cssColor = useCssColor(color) 40 | return ( 41 | 16 | )} 17 | 20 | {(copyState === 'failure' || downloadState === 'failure') && ( 21 |

Something went wrong. Try again later

22 | )} 23 | 24 | ) 25 | } 26 | 27 | function copyButtonTitle(copyState: CopyState): string { 28 | switch (copyState) { 29 | case 'idle': { 30 | return 'copy as png' 31 | } 32 | case 'preparing': { 33 | return 'preparing...' 34 | } 35 | case 'failure': { 36 | return 'copy as png' 37 | } 38 | case 'success': { 39 | return 'copied!' 40 | } 41 | } 42 | } 43 | 44 | function downloadButtonTitle(downloadState: DownloadState): string { 45 | switch (downloadState) { 46 | case 'idle': { 47 | return 'download as png' 48 | } 49 | case 'preparing': { 50 | return 'preparing...' 51 | } 52 | case 'failure': { 53 | return 'download as png' 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from 'react' 2 | 3 | export function minBy( 4 | array: T[], 5 | value: (item: T) => number, 6 | ): [T, number] | null { 7 | if (array.length === 0) { 8 | return null 9 | } 10 | 11 | let minIndex = 0 12 | let minItem = array[minIndex] 13 | let minValue = value(minItem) 14 | 15 | range(1, array.length).map((index) => { 16 | const currentValue = value(array[index]) 17 | if (currentValue < minValue) { 18 | minValue = currentValue 19 | minItem = array[index] 20 | minIndex = index 21 | } 22 | }) 23 | 24 | return [minItem, minIndex] 25 | } 26 | 27 | export function range(min: number, max: number): number[] { 28 | if (max < min) { 29 | return [] 30 | } 31 | return new Array(max - min).fill(0).map((_, index) => min + index) 32 | } 33 | 34 | export function isMonotonous(a: number, b: number, c: number): boolean { 35 | return (a <= b && b <= c) || (a >= b && b >= c) 36 | } 37 | 38 | export function findLast( 39 | array: T[], 40 | predicate: (item: T, index: number) => boolean, 41 | ): [T, number] | null { 42 | for (let index = array.length - 1; index >= 0; index--) { 43 | const item = array[index] 44 | if (predicate(item, index)) { 45 | return [item, index] 46 | } 47 | } 48 | 49 | return null 50 | } 51 | 52 | export function pointFromEvent( 53 | event: MouseEvent, 54 | container: HTMLElement | SVGSVGElement, 55 | ) { 56 | const containerRect = container.getBoundingClientRect() 57 | return { 58 | x: event.clientX - containerRect.left, 59 | y: event.clientY - containerRect.top, 60 | } 61 | } 62 | 63 | // via https://stackoverflow.com/a/51399781/3813902 64 | export type ArrayElement = 65 | ArrayType extends readonly (infer ElementType)[] ? ElementType : never 66 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | export type File = { 2 | owner: string 3 | repo: string 4 | commitSha: string 5 | path: string 6 | } 7 | 8 | export type ParseError = 9 | | { type: 'notGithubUrl'; hostname: string } 10 | | { type: 'notGithubPermalink'; pathname: string } 11 | 12 | type ParseResult = 13 | | { type: 'success'; file: File } 14 | | { type: 'failure'; error: ParseError } 15 | 16 | // extracts info from a GitHub url: 17 | // https://github.com///blob// 18 | // Throws a ParseError if the url doesn't match the expected pattern 19 | export function parsePath(path: string): ParseResult { 20 | const url = new URL(path, 'https://github.com') 21 | if (url.hostname !== 'github.com') { 22 | return { 23 | type: 'failure', 24 | error: { type: 'notGithubUrl', hostname: url.hostname }, 25 | } 26 | } 27 | const [, owner, repo, theWordBlob, commitSha, ...filePathComponents] = 28 | url.pathname.split('/') 29 | 30 | if ( 31 | theWordBlob !== 'blob' || 32 | filePathComponents.length === 0 || 33 | !/^[a-fA-F0-9]{5,40}$/.test(commitSha) 34 | ) { 35 | return { 36 | type: 'failure', 37 | error: { type: 'notGithubPermalink', pathname: url.pathname }, 38 | } 39 | } 40 | 41 | return { 42 | type: 'success', 43 | file: { owner, repo, commitSha, path: filePathComponents.join('/') }, 44 | } 45 | } 46 | 47 | // fetches the raw (text) contents of a file on GitHub 48 | export async function fetchCode({ 49 | owner, 50 | repo, 51 | commitSha, 52 | path, 53 | }: File): Promise { 54 | const contentsResponse = await fetch( 55 | `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${commitSha}`, 56 | ) 57 | 58 | const contentsJson = await contentsResponse.json() 59 | const downloadUrl = contentsJson.download_url 60 | if (!downloadUrl) { 61 | throw new Error(`There was a problem fetching the code from GitHub`) 62 | } 63 | const codeResponse = await fetch(downloadUrl) 64 | return await codeResponse.text() 65 | } 66 | 67 | export function pathForFile(file: File): string { 68 | return `${file.owner}/${file.repo}/blob/${file.commitSha}/${file.path}` 69 | } 70 | -------------------------------------------------------------------------------- /src/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import { Color } from '../colors' 3 | import useCssColor from '../hooks/useCssColor' 4 | import { toggleLineAnnotation } from '../reducer' 5 | import { useDispatch, useSelector } from '../store' 6 | import CodeAnnotations from './CodeAnnotations' 7 | 8 | export default function Code({ code }: { code: string }) { 9 | const lines = code.split('\n') 10 | 11 | return ( 12 |
13 | {lines.map((line, index) => ( 14 | 15 | ))} 16 | 17 |
18 | ) 19 | } 20 | 21 | function Line({ lineNumber, line }: { lineNumber: number; line: string }) { 22 | const dispatch = useDispatch() 23 | const annotations = useSelector( 24 | (state) => state.lineAnnotations[lineNumber] ?? {}, 25 | ) 26 | return ( 27 | 28 | 31 | dispatch(toggleLineAnnotation({ lineNumber, color })) 32 | } 33 | /> 34 | 35 | {lineNumber} 36 | 37 |
42 |         {line}
43 |       
44 |
45 | ) 46 | } 47 | 48 | const annotationColors: Color[] = ['pink', 'blue', 'green'] 49 | 50 | function Annotations({ 51 | annotations, 52 | toggleAnnotation, 53 | }: { 54 | annotations: Record 55 | toggleAnnotation: (color: Color) => void 56 | }) { 57 | return ( 58 |
59 | {annotationColors.map((color) => ( 60 | 66 | ))} 67 |
68 | ) 69 | } 70 | 71 | function AnnotationButton({ 72 | color, 73 | annotations, 74 | toggleAnnotation, 75 | }: { 76 | color: Color 77 | annotations: Record 78 | toggleAnnotation: (color: Color) => void 79 | }) { 80 | const cssColor = useCssColor(color) 81 | 82 | return ( 83 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/components/SelectionPopover.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallowEqual } from 'react-redux' 3 | import { 4 | addMarker, 5 | removeArrow, 6 | removeMarker, 7 | setArrowColor, 8 | setMarkerColor, 9 | } from '../reducer' 10 | import { useDispatch, useSelector } from '../store' 11 | import { Arrow, Marker, Point, TextSelection } from '../types' 12 | import ColorPicker from './ColorPicker' 13 | import { Popover } from './Popover' 14 | 15 | export default function SelectionPopover() { 16 | const currentSelection = useSelector( 17 | (state) => state.currentSelection, 18 | shallowEqual, 19 | ) 20 | 21 | if (!currentSelection) { 22 | return null 23 | } 24 | 25 | switch (currentSelection.type) { 26 | case 'text': { 27 | return 28 | } 29 | case 'marker': { 30 | return 31 | } 32 | case 'arrow': { 33 | return ( 34 | 38 | ) 39 | } 40 | } 41 | } 42 | 43 | function TextPopover({ textSelection }: { textSelection: TextSelection }) { 44 | const dispatch = useDispatch() 45 | const colors = useSelector((state) => state.colors, shallowEqual) 46 | return ( 47 | 53 | dispatch(addMarker({ textSelection, color }))} 56 | /> 57 | 58 | ) 59 | } 60 | 61 | function MarkerPopover({ marker }: { marker: Marker }) { 62 | const dispatch = useDispatch() 63 | const colors = useSelector((state) => state.colors, shallowEqual) 64 | return ( 65 | 73 | 74 | dispatch(setMarkerColor({ marker, color }))} 77 | selectedColor={marker.color} 78 | /> 79 | 80 | ) 81 | } 82 | 83 | function ArrowPopover({ arrow, point }: { arrow: Arrow; point: Point }) { 84 | const dispatch = useDispatch() 85 | const colors = useSelector((state) => state.colors, shallowEqual) 86 | const selectedColor = useSelector( 87 | (state) => arrow.color ?? state.markers[arrow.fromMarker].color, 88 | ) 89 | return ( 90 | 91 | 92 | dispatch(setArrowColor({ arrow, color }))} 95 | selectedColor={selectedColor} 96 | /> 97 | 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/hooks/useTextSelectionHandler.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { clearSelection, selectText } from '../reducer' 3 | import { useDispatch, useSelector } from '../store' 4 | import { TextRange } from '../types' 5 | import { useContainer } from './useContainer' 6 | 7 | export default function useTextSelectionHandler() { 8 | const dispatch = useDispatch() 9 | const { containerRef } = useContainer() 10 | const isTextCurrentlySelected = useSelector( 11 | (state) => state.currentSelection?.type === 'text', 12 | ) 13 | 14 | const handler = React.useCallback(() => { 15 | if (!containerRef.current) { 16 | throw new Error(`Invalid container ref`) 17 | } 18 | const selection = document.getSelection() 19 | if ( 20 | !selection || 21 | selection.type !== 'Range' || 22 | (selection.anchorNode?.parentElement?.className !== 'code-line' && 23 | selection.focusNode?.parentElement?.className !== 'code-line') || 24 | selection.toString().trim().includes('\n') 25 | ) { 26 | if (isTextCurrentlySelected) { 27 | dispatch(clearSelection()) 28 | } 29 | } else { 30 | const rect = selectionRect(selection) 31 | const textRange = selectionTextRange(selection) 32 | const parentRect = containerRef.current.getBoundingClientRect() 33 | const rectInContainerCoordinates = { 34 | top: rect.top - parentRect.top, 35 | left: rect.left - parentRect.left, 36 | width: rect.width, 37 | height: rect.height, 38 | bottom: rect.bottom - parentRect.top, 39 | right: rect.right - parentRect.left, 40 | ...textRange, 41 | } 42 | dispatch(selectText(rectInContainerCoordinates)) 43 | } 44 | }, [isTextCurrentlySelected]) 45 | 46 | return handler 47 | } 48 | 49 | function selectionRect(selection: Selection): DOMRect { 50 | const range = selection.getRangeAt(0) 51 | const rects = Array.from(range.getClientRects()).filter( 52 | (rect) => rect.width > 0 && rect.height > 0, 53 | ) 54 | switch (rects.length) { 55 | case 1: { 56 | return rects[0] 57 | } 58 | case 2: { 59 | const selectionString = selection.toString() 60 | if (selectionString.startsWith('\n')) { 61 | return rects[1] 62 | } else if (selectionString.endsWith('\n')) { 63 | return rects[0] 64 | } 65 | } 66 | default: { 67 | throw new Error(`Can't handle a selection that has ${rects.length} rects`) 68 | } 69 | } 70 | } 71 | 72 | function selectionTextRange(selection: Selection): TextRange { 73 | const selectionString = selection.toString().replace('\n', '') 74 | const range = selection.getRangeAt(0) 75 | const lineNumber = parseInt( 76 | selection.anchorNode?.parentElement?.dataset.lineNumber ?? '', 77 | ) 78 | if (isNaN(lineNumber)) { 79 | throw new Error( 80 | `Can't parse the line number of the current text selection ('${selection.toString()}')`, 81 | ) 82 | } 83 | const endOffset = 84 | range.endOffset === 0 85 | ? range.startOffset + selectionString.length 86 | : range.endOffset 87 | return { 88 | text: selectionString, 89 | lineNumber, 90 | startOffset: range.startOffset, 91 | endOffset, 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/components/CodeAnnotations.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { 3 | ArrowDrawingProvider, 4 | useCurrentArrowDrawing, 5 | useDrawingEventHandlers, 6 | } from '../hooks/useArrowDrawing' 7 | import { ContainerDiv } from '../hooks/useContainer' 8 | import useTextSelectionHandler from '../hooks/useTextSelectionHandler' 9 | import { useSelector } from '../store' 10 | import { FinishedArrowLine, UnfinishedArrowLine } from './ArrowLine' 11 | import MarkerRect from './MarkerRect' 12 | import SelectionPopover from './SelectionPopover' 13 | 14 | export default function CodeAnnotations({ 15 | numberOfLines, 16 | }: { 17 | numberOfLines: number 18 | }) { 19 | return ( 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ) 33 | } 34 | 35 | function Svg() { 36 | const currentArrow = useCurrentArrowDrawing() 37 | const drawing = useDrawingEventHandlers() 38 | 39 | const selectionChangeHandler = useTextSelectionHandler() 40 | useEffect(() => { 41 | document.onselectionchange = selectionChangeHandler 42 | }, [selectionChangeHandler]) 43 | 44 | return ( 45 | drawing.onMouseMove(event, null)} 50 | onClick={(event) => drawing.onClick(event, null)} 51 | > 52 | 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | function Arrows() { 60 | const arrows = useSelector((state) => Object.values(state.arrows)) 61 | const currentSelection = useSelector((state) => state.currentSelection) 62 | 63 | return ( 64 | <> 65 | {arrows.map((arrow) => ( 66 | 76 | ))} 77 | 78 | ) 79 | } 80 | 81 | function Markers() { 82 | const markers = useSelector((state) => Object.values(state.markers)) 83 | const currentSelection = useSelector((state) => state.currentSelection) 84 | 85 | return ( 86 | <> 87 | {markers.map((marker) => ( 88 | 93 | ))} 94 | 95 | ) 96 | } 97 | 98 | function DrawingCancelationButton() { 99 | const currentArrow = useCurrentArrowDrawing() 100 | const drawing = useDrawingEventHandlers() 101 | if (!currentArrow) { 102 | return null 103 | } 104 | return ( 105 | 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /src/undoable.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, createAction, Reducer } from '@reduxjs/toolkit' 2 | import { shallowEqual, useSelector } from 'react-redux' 3 | 4 | type StateWrapper = State & { 5 | past: StateSlice[] 6 | future: StateSlice[] 7 | } 8 | type ActionWrapper = 9 | | Action 10 | | ReturnType 11 | | ReturnType 12 | | ReturnType 13 | 14 | export const undo = createAction('undoable/undo') 15 | export const redo = createAction('undoable/redo') 16 | export const reset = createAction('undoable/reset') 17 | 18 | export default function undoable< 19 | State extends StateSlice, 20 | Action extends AnyAction, 21 | StateSlice, 22 | >( 23 | reducer: Reducer, 24 | slice: (state: State) => StateSlice, 25 | shouldLogAction: (action: Action) => boolean, 26 | clearSlice: StateSlice, 27 | ): Reducer, ActionWrapper> { 28 | return (state, action) => { 29 | if (!state) { 30 | const initialState = reducer(undefined, action as Action) 31 | return { ...initialState, past: [], future: [] } 32 | } 33 | switch (action.type) { 34 | case undo.type: { 35 | const { past, future, ...currentState } = state 36 | const newState = past[0] 37 | if (!newState) { 38 | return state 39 | } 40 | return { 41 | ...state, 42 | ...newState, 43 | past: past.slice(1), 44 | future: [slice(currentState as unknown as State), ...future], 45 | } 46 | } 47 | case redo.type: { 48 | const { past, future, ...currentState } = state 49 | const newState = future[0] 50 | if (!newState) { 51 | return state 52 | } 53 | return { 54 | ...state, 55 | ...newState, 56 | past: [slice(currentState as unknown as State), ...past], 57 | future: future.slice(1), 58 | } 59 | } 60 | case reset.type: { 61 | const { past, future, ...currentState } = state 62 | const newState = { ...currentState, ...clearSlice } as unknown as State 63 | if (shallowEqual(currentState, newState)) { 64 | return state 65 | } else { 66 | return { 67 | ...newState, 68 | past: [slice(currentState as unknown as State), ...past], 69 | future: [], 70 | } 71 | } 72 | } 73 | default: { 74 | const { past, future, ...currentState } = state 75 | const newState = reducer( 76 | currentState as unknown as State, 77 | action as Action, 78 | ) 79 | const newPast = shouldLogAction(action as Action) 80 | ? [slice(currentState as unknown as State), ...past] 81 | : past 82 | return { 83 | ...newState, 84 | past: newPast, 85 | future: [], 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | export function useCanUndoRedo(): { 93 | canUndo: boolean 94 | canRedo: boolean 95 | } { 96 | const canUndo = useSelector( 97 | (state: StateWrapper) => state.past.length > 0, 98 | ) 99 | const canRedo = useSelector( 100 | (state: StateWrapper) => state.future.length > 0, 101 | ) 102 | 103 | return { canUndo, canRedo } 104 | } 105 | -------------------------------------------------------------------------------- /src/pages/AnnotationPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | import { PersistGate } from 'redux-persist/integration/react' 4 | import Code from '../components/Code' 5 | import Controls from '../components/Controls' 6 | import { Footer } from '../components/Footer' 7 | import useCode from '../hooks/useCode' 8 | import useKeyboardHandler from '../hooks/useKeyboardHandler' 9 | import useSource from '../hooks/useSource' 10 | import { clearSelection, removeArrow, removeMarker } from '../reducer' 11 | import createStore, { useDispatch, useSelector } from '../store' 12 | import { redo, undo } from '../undoable' 13 | 14 | export default function AnnotationPage() { 15 | const source = useSource() 16 | const { store, persistor } = createStore(source) 17 | 18 | return ( 19 | 20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 |
29 | ) 30 | } 31 | 32 | function AnnotationPageContent() { 33 | const source = useSource() 34 | const { code, error } = useCode(source) 35 | 36 | if (code) { 37 | return 38 | } else { 39 | return 40 | } 41 | } 42 | 43 | function LoadingPageContent({ error }: { error: Error | null }) { 44 | if (error) { 45 | return ( 46 |
Oh no! Something went wrong while trying to fetch the code.
47 | ) 48 | } else { 49 | return
Fetching code...
50 | } 51 | } 52 | 53 | function LoadedPageContent({ code }: { code: string }) { 54 | useReducerKeyboardHandler() 55 | 56 | return ( 57 | <> 58 | 59 |
60 | 61 |
62 | 63 | ) 64 | } 65 | 66 | function useReducerKeyboardHandler() { 67 | const dispatch = useDispatch() 68 | const currentSelection = useSelector((state) => state.currentSelection) 69 | 70 | const handler = React.useCallback( 71 | (event: KeyboardEvent) => { 72 | const isMetaOn = event.ctrlKey || event.metaKey 73 | if (event.key === 'z' && isMetaOn && event.shiftKey) { 74 | // redo 75 | event.preventDefault() 76 | event.stopPropagation() 77 | dispatch(redo()) 78 | } else if (event.key === 'z' && isMetaOn) { 79 | // undo 80 | event.preventDefault() 81 | event.stopPropagation() 82 | dispatch(undo()) 83 | } else if ( 84 | ['Backspace', 'Delete'].includes(event.key) && 85 | currentSelection 86 | ) { 87 | // remove currently selected marker/arrow 88 | switch (currentSelection.type) { 89 | case 'text': { 90 | return 91 | } 92 | case 'marker': { 93 | dispatch(removeMarker(currentSelection.marker)) 94 | return 95 | } 96 | case 'arrow': { 97 | dispatch(removeArrow(currentSelection.arrow)) 98 | return 99 | } 100 | } 101 | } else if (event.key === 'Escape' && currentSelection) { 102 | dispatch(clearSelection()) 103 | if (currentSelection.type === 'text') { 104 | document.getSelection()?.removeAllRanges() 105 | } 106 | } 107 | }, 108 | [dispatch, currentSelection], 109 | ) 110 | 111 | useKeyboardHandler(handler) 112 | } 113 | -------------------------------------------------------------------------------- /src/pages/SourceSelectionPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, FormEvent, useCallback, useState } from 'react' 2 | import { useHistory } from 'react-router-dom' 3 | import * as github from '../github' 4 | import { Source, sourceHash } from '../source' 5 | import { Footer } from './../components/Footer' 6 | 7 | export default function SourceSelectionPage() { 8 | return ( 9 |
10 |
11 |

Code Annotation Tool

12 |
    13 |
  1. Find a file you want to annotate on Github
  2. 14 |
  3. 15 | 16 | Press Y 17 | {' '} 18 | (this will change the url to a permalink) 19 |
  4. 20 |
  5. Copy the url
  6. 21 |
  7. Paste it below
  8. 22 |
23 |
24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | function Form() { 31 | const [url, setUrl] = useState('') 32 | const urlParseResult = github.parsePath(url) 33 | const history = useHistory() 34 | 35 | const onSubmit = useCallback( 36 | (e: FormEvent) => { 37 | e.preventDefault() 38 | if (urlParseResult.type !== 'success') { 39 | return 40 | } 41 | const source: Source = { 42 | type: 'githubPermalink', 43 | file: urlParseResult.file, 44 | } 45 | history.push(`/file/${sourceHash(source)}`) 46 | }, 47 | [url], 48 | ) 49 | 50 | const onType = useCallback((e: ChangeEvent) => { 51 | setUrl(e.target.value) 52 | }, []) 53 | 54 | return ( 55 | 56 |
57 | 63 | 64 |
65 | {url !== '' && urlParseResult.type === 'failure' && ( 66 | 67 | )} 68 | 69 | ) 70 | } 71 | 72 | function ErrorMessage({ error }: { error: github.ParseError }) { 73 | switch (error.type) { 74 | case 'notGithubUrl': { 75 | return

Please provide a permalink to a GitHub file.

76 | } 77 | case 'notGithubPermalink': { 78 | return ( 79 |
80 |

81 | That's not a GitHub permalink. 82 |

83 |

84 | You can get a permalink for a file on GitHub by pressing{' '} 85 | Y on your keyboard: 86 |

87 |
88 | 92 | https://github.com/CodeReadingClubs/annotation-tool/blob/main/src/pages/AnnotationPage.tsx 93 | 94 | 95 | 96 | 97 | Press Y 98 | 99 | 100 | 101 | The url will change 102 | 103 | 107 | https://github.com/CodeReadingClubs/annotation-tool/blob/13de8c73ea369966d3e0843b0392ffb67d1015a4/src/pages/AnnotationPage.tsx 108 | 109 | 110 | Copy it and paste it here 111 |
112 |
113 | ) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/components/ArrowLine.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent } from 'react' 2 | import { arrowAngleForPoints, pointArrayForArrow } from '../geometry' 3 | import { 4 | useCurrentArrowDrawing, 5 | useDrawingEventHandlers, 6 | } from '../hooks/useArrowDrawing' 7 | import { useContainer } from '../hooks/useContainer' 8 | import useCssColor from '../hooks/useCssColor' 9 | import { selectArrow } from '../reducer' 10 | import { useDispatch, useSelector } from '../store' 11 | import { Arrow, UnfinishedArrow } from '../types' 12 | 13 | type Props = { 14 | arrow: Arrow | UnfinishedArrow 15 | highlighted: boolean 16 | selectable: boolean 17 | onContextMenu?: (event: MouseEvent) => void 18 | onClick?: (event: MouseEvent) => void 19 | } 20 | 21 | function ArrowLine({ 22 | arrow, 23 | highlighted, 24 | selectable, 25 | onContextMenu, 26 | onClick, 27 | }: Props) { 28 | const toMarker = useSelector((state) => 29 | arrow.toMarker ? state.markers[arrow.toMarker] : null, 30 | ) 31 | const points = pointArrayForArrow(arrow, toMarker) 32 | const endPoint = points[points.length - 1] 33 | const arrowAngle = arrowAngleForPoints(points) 34 | const pointsString = points.map(({ x, y }) => `${x},${y}`).join(' ') 35 | const color = useSelector( 36 | (state) => arrow.color ?? state.markers[arrow.fromMarker].color, 37 | ) 38 | const cssColor = useCssColor(color) 39 | 40 | const hasMouseEvents = onContextMenu !== undefined || onClick !== undefined 41 | const strokeWidth = highlighted ? 5 : 3 42 | return ( 43 | 51 | {hasMouseEvents && ( 52 | 59 | )} 60 | 66 | 67 | {arrowAngle && ( 68 | 76 | )} 77 | {arrowAngle && ( 78 | 86 | )} 87 | 88 | ) 89 | } 90 | 91 | type FinishedArrowLineProps = { 92 | arrow: Arrow 93 | highlighted: boolean 94 | selectable: boolean 95 | } 96 | 97 | export function FinishedArrowLine({ 98 | arrow, 99 | highlighted, 100 | selectable, 101 | }: FinishedArrowLineProps) { 102 | const dispatch = useDispatch() 103 | const drawing = useDrawingEventHandlers() 104 | const { eventCoordinates } = useContainer() 105 | const onContextMenu = React.useCallback( 106 | (event: MouseEvent) => { 107 | event.preventDefault() 108 | drawing.cancelArrow() 109 | dispatch(selectArrow({ arrow, point: eventCoordinates(event) })) 110 | }, 111 | [eventCoordinates, drawing.cancelArrow], 112 | ) 113 | 114 | return ( 115 | drawing.onClick(event, arrow)} 121 | /> 122 | ) 123 | } 124 | 125 | export function UnfinishedArrowLine() { 126 | const arrow = useCurrentArrowDrawing() 127 | if (!arrow) { 128 | return null 129 | } 130 | return 131 | } 132 | -------------------------------------------------------------------------------- /src/hooks/useExport.ts: -------------------------------------------------------------------------------- 1 | import html2canvas from 'html2canvas' 2 | import { useCallback, useState } from 'react' 3 | 4 | export type CopyState = 'idle' | 'preparing' | 'success' | 'failure' 5 | 6 | export type DownloadState = 'idle' | 'preparing' | 'failure' 7 | 8 | type ReturnType = { 9 | copy: () => void 10 | download: () => void 11 | copyState: CopyState 12 | downloadState: DownloadState 13 | } 14 | 15 | export default function useExport(): ReturnType { 16 | const [copyState, setCopyState] = useState('idle') 17 | const [downloadState, setDownloadState] = useState('idle') 18 | 19 | const copy = useCallback(() => { 20 | setCopyState('preparing') 21 | copyContainer() 22 | .then(() => setCopyState('success')) 23 | .then(() => wait(1000)) 24 | .then(() => setCopyState('idle')) 25 | .catch(() => setCopyState('failure')) 26 | }, []) 27 | 28 | const download = useCallback(() => { 29 | setDownloadState('preparing') 30 | downloadContainer() 31 | .then(() => setDownloadState('idle')) 32 | .catch(() => setDownloadState('failure')) 33 | }, []) 34 | 35 | return { copy, download, copyState, downloadState } 36 | } 37 | 38 | async function copyContainer() { 39 | if (!navigator.clipboard.write) { 40 | throw new Error(`Can't use clipboard API`) 41 | } 42 | try { 43 | const clipboardItem = new ClipboardItem({ 44 | 'image/png': containerAsCanvas().then(canvasToBlob), 45 | }) 46 | await navigator.clipboard.write([clipboardItem]) 47 | } catch (error) { 48 | // Safari only allows access to the clipboard in user events, but async 49 | // functions inside user events (like awaiting the creation of a 50 | // canvas-image blob) aren't considered user-event context, 51 | // even if those promises were fired in an event handler. 52 | // The way to work around this is to initialize ClipboardItem with a 53 | // Promise. HOWEVER, this doesn't work in Chromium, so we need this 54 | // highly fragile workaround. 55 | if ( 56 | error instanceof Error && 57 | error.message === 58 | "Failed to construct 'ClipboardItem': Failed to convert value to 'Blob'." 59 | ) { 60 | const blob = await containerAsCanvas().then(canvasToBlob) 61 | const clipboardItem = new ClipboardItem({ 62 | 'image/png': blob as unknown as Promise, 63 | }) 64 | await navigator.clipboard.write([clipboardItem]) 65 | } else { 66 | throw error 67 | } 68 | } 69 | } 70 | 71 | async function downloadContainer() { 72 | const canvas = await containerAsCanvas() 73 | const blob = await canvasToBlob(canvas) 74 | downloadBlob(blob, 'code.png') 75 | } 76 | 77 | async function containerAsCanvas(): Promise { 78 | const container = document.getElementsByClassName( 79 | 'container', 80 | )[0] as HTMLElement 81 | if (!container) { 82 | throw new Error(`Couldn't find container element`) 83 | } 84 | const padding = 20 85 | const windowWidth = container.scrollWidth + 2 * padding 86 | const canvas = await html2canvas(container, { 87 | logging: false, 88 | onclone: (document, element) => { 89 | document.body.style.margin = '0' 90 | element.style.border = `${padding}px solid white` 91 | const svg = document.getElementsByTagName('svg')[0] 92 | svg.style.width = `${windowWidth}px` 93 | svg.style.inlineSize = `${windowWidth}px` 94 | }, 95 | windowWidth, 96 | }) 97 | 98 | return canvas 99 | } 100 | 101 | function canvasToBlob(canvas: HTMLCanvasElement): Promise { 102 | return new Promise((resolve, reject) => { 103 | canvas.toBlob((blob) => { 104 | if (!blob) { 105 | reject(new Error(`Couldn't convert canvas to blob`)) 106 | } else { 107 | resolve(blob) 108 | } 109 | }, 'image/png') 110 | }) 111 | } 112 | 113 | function downloadBlob(blob: Blob, filename: string) { 114 | const href = URL.createObjectURL(blob) 115 | const a = document.createElement('a') 116 | a.href = href 117 | a.download = filename 118 | document.body.appendChild(a) 119 | a.click() 120 | document.body.removeChild(a) 121 | } 122 | 123 | async function wait(duration: number): Promise { 124 | return new Promise((resolve) => { 125 | setTimeout(() => resolve(), duration) 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /src/geometry.ts: -------------------------------------------------------------------------------- 1 | import { Arrow, Marker, Point, Rect, UnfinishedArrow } from './types' 2 | import { findLast, isMonotonous, minBy, range } from './util' 3 | 4 | export function distanceBetweenPoints(a: Point, b: Point): number { 5 | return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)) 6 | } 7 | 8 | export function pointArrayForArrow( 9 | arrow: Arrow | UnfinishedArrow, 10 | toMarker: Marker | null, 11 | ): Point[] { 12 | const allPoints = [arrow.fromPoint, ...arrow.midPoints, arrow.toPoint] 13 | 14 | if (!toMarker || allPoints.every((pt) => !isPointInRect(pt, toMarker))) { 15 | return allPoints 16 | } 17 | 18 | const lastIndex = findLast( 19 | allPoints, 20 | (_, index) => 21 | index > 0 && 22 | lineRectIntersection(allPoints[index - 1], allPoints[index], toMarker) !== 23 | null, 24 | )?.[1] 25 | 26 | if (lastIndex === undefined) { 27 | throw new Error(`Can't find the intersection of a line with a marker`) 28 | } 29 | 30 | const intersection = lineRectIntersection( 31 | allPoints[lastIndex], 32 | allPoints[lastIndex - 1], 33 | toMarker, 34 | )! 35 | return [...allPoints.slice(0, lastIndex), intersection] 36 | } 37 | 38 | export function arrowAngleForPoints(points: Point[]): number | null { 39 | const n = points.length 40 | if (distanceBetweenPoints(points[0], points[n - 1]) < 40) { 41 | return null 42 | } 43 | 44 | const lastPoint = points[n - 1] 45 | const secondToLastPoint = findLast( 46 | points, 47 | (pt) => distanceBetweenPoints(pt, lastPoint) > 5, 48 | ) 49 | if (!secondToLastPoint) { 50 | return null 51 | } 52 | 53 | return Math.atan2( 54 | lastPoint.y - secondToLastPoint[0].y, 55 | lastPoint.x - secondToLastPoint[0].x, 56 | ) 57 | } 58 | 59 | export function lineRectIntersection( 60 | p1: Point, 61 | p2: Point, 62 | rect: Rect, 63 | ): Point | null { 64 | const intersections = [ 65 | horizontalLineIntersection(p1, p2, rect.left, rect.right, rect.top), 66 | horizontalLineIntersection(p1, p2, rect.left, rect.right, rect.bottom), 67 | verticalLineIntersection(p1, p2, rect.left, rect.top, rect.bottom), 68 | verticalLineIntersection(p1, p2, rect.right, rect.top, rect.bottom), 69 | ].filter( 70 | (intersection): intersection is [number, Point] => intersection !== null, 71 | ) 72 | 73 | const firstIntersection = minBy(intersections, ([t]) => t) 74 | if (!firstIntersection) { 75 | return null 76 | } 77 | 78 | return firstIntersection[0][1] 79 | } 80 | 81 | function horizontalLineIntersection( 82 | p1: Point, 83 | p2: Point, 84 | x1: number, 85 | x2: number, 86 | y: number, 87 | ): [number, Point] | null { 88 | if (p1.y === p2.y) { 89 | return null 90 | } 91 | 92 | const t = (y - p1.y) / (p2.y - p1.y) 93 | const x = p1.x + t * (p2.x - p1.x) 94 | 95 | if (isMonotonous(0, t, 1) && isMonotonous(x1, x, x2)) { 96 | return [t, { x, y }] 97 | } else { 98 | return null 99 | } 100 | } 101 | 102 | function verticalLineIntersection( 103 | p1: Point, 104 | p2: Point, 105 | x: number, 106 | y1: number, 107 | y2: number, 108 | ): [number, Point] | null { 109 | if (p1.x === p2.x) { 110 | return null 111 | } 112 | 113 | const t = (x - p1.x) / (p2.x - p1.x) 114 | const y = p1.y + t * (p2.y - p1.y) 115 | 116 | if (isMonotonous(0, t, 1) && isMonotonous(y1, y, y2)) { 117 | return [t, { x, y }] 118 | } else { 119 | return null 120 | } 121 | } 122 | 123 | export function pointOnPolylineNearPoint(p: Point, polyline: Point[]): Point { 124 | const pointsOnSegments = pairs(polyline).map((segment) => 125 | pointOnLineNearPoint(...segment, p), 126 | ) 127 | 128 | const pointOnNearestSegment = minBy(pointsOnSegments, (pt) => 129 | distanceBetweenPoints(p, pt), 130 | ) 131 | if (!pointOnNearestSegment) { 132 | throw new Error(`Not enough points on polyline`) 133 | } 134 | 135 | return pointOnNearestSegment[0] 136 | } 137 | 138 | function pairs(array: T[]): [T, T][] { 139 | return range(0, array.length - 1).map((index) => [ 140 | array[index], 141 | array[index + 1], 142 | ]) 143 | } 144 | 145 | function pointOnLineNearPoint( 146 | p1: Point, 147 | p2: Point, 148 | { x: x0, y: y0 }: Point, 149 | ): Point { 150 | const d = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)) 151 | const n = { x: (p2.x - p1.x) / d, y: (p2.y - p1.y) / d } 152 | const t0 = (x0 - p1.x) * n.x + (y0 - p1.y) * n.y 153 | if (t0 <= 0) { 154 | return p1 155 | } else if (t0 >= d) { 156 | return p2 157 | } 158 | 159 | return { x: p1.x + t0 * n.x, y: p1.y + t0 * n.y } 160 | } 161 | 162 | export function isPointInRect( 163 | { x, y }: Point, 164 | { top, bottom, left, right }: Rect, 165 | ): boolean { 166 | return isMonotonous(left, x, right) && isMonotonous(top, y, bottom) 167 | } 168 | -------------------------------------------------------------------------------- /src/components/Controls.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { Brightness } from '../colors' 3 | import { ArrowDrawingMode, useSettings } from '../hooks/useSettings' 4 | import useSource from '../hooks/useSource' 5 | import { localStorageKey } from '../source' 6 | import { useDispatch } from '../store' 7 | import { redo, reset, undo, useCanUndoRedo } from '../undoable' 8 | import Export from './Export' 9 | 10 | export default function Controls() { 11 | const dispatch = useDispatch() 12 | const { arrowDrawingMode, setArrowDrawingMode } = useSettings() 13 | 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 | {import.meta.env.DEV && } 21 |
22 | ) 23 | } 24 | 25 | const drawingModeOptions: { title: string; value: ArrowDrawingMode }[] = [ 26 | { title: 'Freehand', value: 'freehand' }, 27 | { title: 'Jointed', value: 'jointed' }, 28 | ] 29 | 30 | function DrawingMode() { 31 | const { arrowDrawingMode, setArrowDrawingMode } = useSettings() 32 | 33 | return ( 34 |
35 | Arrow drawing mode: 36 | 42 |
43 | ) 44 | } 45 | 46 | const brightnessOptions: { title: string; value: Brightness }[] = [ 47 | { title: 'Light', value: 'light' }, 48 | { title: 'Medium', value: 'medium' }, 49 | { title: 'Dark', value: 'dark' }, 50 | ] 51 | 52 | function AnnotationBrightness() { 53 | const { annotationBrightness, setAnnotationBrightness } = useSettings() 54 | 55 | return ( 56 |
57 | Annotation brightness: 58 | 64 |
65 | ) 66 | } 67 | 68 | function UndoManagement() { 69 | const dispatch = useDispatch() 70 | const { canUndo, canRedo } = useCanUndoRedo() 71 | 72 | return ( 73 |
74 | 77 | 80 | 81 |
82 | ) 83 | } 84 | 85 | function PersistedStateDebugging() { 86 | const source = useSource() 87 | const paste = useCallback(() => { 88 | const key = `persist:state:${localStorageKey(source)}` 89 | navigator.clipboard 90 | .readText() 91 | .then((text) => localStorage.setItem(key, text)) 92 | .then(() => console.log('pasted')) 93 | }, []) 94 | 95 | const copy = useCallback(() => { 96 | const key = `persist:state:${localStorageKey(source)}` 97 | const localStorageValue = localStorage.getItem(key) 98 | if (!localStorageValue) { 99 | console.error(`Value missing in local storage`) 100 | return 101 | } 102 | navigator.clipboard 103 | .writeText(localStorageValue) 104 | .then(() => console.log('copied')) 105 | }, []) 106 | 107 | return ( 108 |
109 | 110 | 111 |
112 | ) 113 | } 114 | 115 | function RadioGroup({ 116 | name, 117 | options, 118 | selectedValue, 119 | onSelect, 120 | }: { 121 | name: string 122 | options: { title: string; value: Value }[] 123 | selectedValue: Value 124 | onSelect: (value: Value) => void 125 | }) { 126 | return ( 127 | <> 128 | {options.map(({ title, value }) => ( 129 | 130 | key={value} 131 | name={name} 132 | title={title} 133 | value={value} 134 | selectedValue={selectedValue} 135 | onSelect={onSelect} 136 | /> 137 | ))} 138 | 139 | ) 140 | } 141 | 142 | function RadioItem({ 143 | title, 144 | name, 145 | value, 146 | selectedValue, 147 | onSelect, 148 | }: { 149 | title: string 150 | name: string 151 | value: Value 152 | selectedValue: Value 153 | onSelect: (value: Value) => void 154 | }) { 155 | const id = `${name}--${value}` 156 | return ( 157 | <> 158 | onSelect(event.target.value as Value)} 165 | /> 166 | 167 | 168 | ) 169 | } 170 | -------------------------------------------------------------------------------- /src/reducer.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | import { v4 as uuid } from 'uuid' 3 | import { Color, colors } from './colors' 4 | import { Arrow, Marker, Point, TextSelection } from './types' 5 | 6 | export type State = { 7 | currentSelection: Selection | null 8 | markers: Record 9 | arrows: Record 10 | lineAnnotations: Record> 11 | colors: Color[] 12 | } 13 | 14 | type Selection = 15 | | { type: 'text'; textSelection: TextSelection } 16 | | { type: 'marker'; marker: Marker } 17 | | { type: 'arrow'; arrow: Arrow; point: Point } 18 | 19 | const initialState: State = { 20 | currentSelection: null, 21 | markers: {}, 22 | arrows: {}, 23 | lineAnnotations: {}, 24 | colors: colors as unknown as Color[], 25 | } 26 | 27 | const { reducer, actions } = createSlice({ 28 | name: 'state', 29 | initialState, 30 | reducers: { 31 | selectText(state, action: PayloadAction) { 32 | state.currentSelection = { type: 'text', textSelection: action.payload } 33 | }, 34 | selectMarker(state, action: PayloadAction) { 35 | state.currentSelection = { type: 'marker', marker: action.payload } 36 | }, 37 | selectArrow(state, action: PayloadAction<{ arrow: Arrow; point: Point }>) { 38 | state.currentSelection = { type: 'arrow', ...action.payload } 39 | }, 40 | clearSelection(state) { 41 | state.currentSelection = null 42 | }, 43 | removeMarker(state, action: PayloadAction) { 44 | delete state.markers[action.payload.id] 45 | const idsToDelete = [] 46 | for (const arrow of Object.values(state.arrows)) { 47 | if (arrow.dependencies[action.payload.id]) { 48 | idsToDelete.push(arrow.id) 49 | } 50 | } 51 | idsToDelete.forEach((id) => delete state.arrows[id]) 52 | state.currentSelection = null 53 | }, 54 | removeArrow(state, action: PayloadAction) { 55 | const idsToDelete = [] 56 | for (const arrow of Object.values(state.arrows)) { 57 | if ( 58 | arrow.dependencies[action.payload.id] || 59 | arrow.id === action.payload.id 60 | ) { 61 | idsToDelete.push(arrow.id) 62 | } 63 | } 64 | idsToDelete.forEach((id) => delete state.arrows[id]) 65 | state.currentSelection = null 66 | }, 67 | addMarker( 68 | state, 69 | action: PayloadAction<{ textSelection: TextSelection; color: Color }>, 70 | ) { 71 | if (state.currentSelection?.type === 'text') { 72 | document.getSelection()?.removeAllRanges() 73 | } 74 | const id = uuid() 75 | state.markers[id] = { 76 | ...action.payload.textSelection, 77 | color: action.payload.color, 78 | id, 79 | } 80 | state.currentSelection = null 81 | }, 82 | addArrow(state, action: PayloadAction) { 83 | state.arrows[action.payload.id] = action.payload 84 | }, 85 | setMarkerColor( 86 | state, 87 | action: PayloadAction<{ marker: Marker; color: Color }>, 88 | ) { 89 | const marker = state.markers[action.payload.marker.id] 90 | if (!marker) { 91 | return 92 | } 93 | marker.color = action.payload.color 94 | state.currentSelection = null 95 | }, 96 | setArrowColor( 97 | state, 98 | action: PayloadAction<{ arrow: Arrow; color: Color }>, 99 | ) { 100 | const arrow = state.arrows[action.payload.arrow.id] 101 | if (!arrow) { 102 | return 103 | } 104 | arrow.color = action.payload.color 105 | state.currentSelection = null 106 | }, 107 | toggleLineAnnotation( 108 | state, 109 | { 110 | payload: { lineNumber, color }, 111 | }: PayloadAction<{ lineNumber: number; color: Color }>, 112 | ) { 113 | const selectedColors = state.lineAnnotations[lineNumber] ?? {} 114 | selectedColors[color] = !(selectedColors[color] ?? false) 115 | state.lineAnnotations[lineNumber] = selectedColors 116 | }, 117 | }, 118 | }) 119 | 120 | export default reducer 121 | export const { 122 | selectText, 123 | addMarker, 124 | selectMarker, 125 | removeMarker, 126 | addArrow, 127 | selectArrow, 128 | removeArrow, 129 | setMarkerColor, 130 | setArrowColor, 131 | clearSelection, 132 | toggleLineAnnotation, 133 | } = actions 134 | 135 | const undoableActions: Set = new Set([ 136 | addMarker.type, 137 | removeMarker.type, 138 | addArrow.type, 139 | removeArrow.type, 140 | toggleLineAnnotation.type, 141 | setMarkerColor.type, 142 | setArrowColor.type, 143 | ]) 144 | 145 | export function isUndoableAction(action: AnyAction): boolean { 146 | return undoableActions.has(action.type) 147 | } 148 | 149 | export const emptyAnnotations: Pick< 150 | State, 151 | 'markers' | 'arrows' | 'lineAnnotations' 152 | > = { 153 | markers: {}, 154 | arrows: {}, 155 | lineAnnotations: {}, 156 | } 157 | 158 | export function undoableSlice({ 159 | markers, 160 | arrows, 161 | lineAnnotations, 162 | colors, 163 | }: State) { 164 | return { markers, arrows, lineAnnotations, colors } 165 | } 166 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 2rem; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | font-size: 16px; 9 | color: #333; 10 | background: white; 11 | } 12 | 13 | @media print { 14 | .annotation-controls { 15 | display: none; 16 | } 17 | 18 | .color-button { 19 | // `color-adjust: exact` makes it so the browser doesn't ignore the 20 | // background color when it prints. 21 | color-adjust: exact; 22 | -webkit-print-color-adjust: exact; 23 | } 24 | } 25 | 26 | .container { 27 | margin-top: 2rem; 28 | 29 | .code-container { 30 | display: grid; 31 | grid-template-columns: auto auto 1fr; 32 | align-items: center; 33 | font-family: 'Roboto Mono', monospace; 34 | row-gap: 0.5rem; 35 | 36 | .line-annotations { 37 | grid-column: 1; 38 | display: flex; 39 | flex-direction: row; 40 | 41 | .color-button { 42 | border-radius: 1000rem; 43 | width: 1rem; 44 | height: 1rem; 45 | 46 | &--active { 47 | opacity: 1; 48 | } 49 | } 50 | 51 | &:hover { 52 | .color-button:not(.color-button--active):not(:hover) { 53 | opacity: 0.5; 54 | } 55 | } 56 | 57 | &:not(:hover) { 58 | .color-button:not(.color-button--active) { 59 | background: #eeeeee; // overrides the default value of var(--color) 60 | } 61 | } 62 | } 63 | 64 | .line-number { 65 | grid-column: 2; 66 | color: gray; 67 | text-align: right; 68 | margin-right: 0.5rem; 69 | margin-left: 0.25rem; 70 | user-select: none; 71 | -moz-user-select: none; 72 | -webkit-user-select: none; 73 | } 74 | 75 | pre { 76 | grid-column: 3; 77 | margin: 0; 78 | font-size: 1rem; 79 | } 80 | } 81 | 82 | .svg-container { 83 | grid-column: 3; 84 | pointer-events: none; 85 | width: 100%; 86 | height: 100%; 87 | position: relative; 88 | 89 | svg { 90 | display: block; 91 | width: 100%; 92 | height: 100%; 93 | mix-blend-mode: multiply; 94 | } 95 | 96 | .drawing-cancelation-button { 97 | pointer-events: auto; 98 | position: sticky; 99 | bottom: 1rem; 100 | left: 1rem; 101 | font-size: 1rem; 102 | max-width: 10rem; 103 | text-align: left; 104 | outline: 0; 105 | padding: 0.5rem; 106 | margin: 0; 107 | border: 1px solid black; 108 | border-radius: 0.25rem; 109 | background: #fff; 110 | 111 | &:hover { 112 | background: #eee; 113 | } 114 | } 115 | } 116 | } 117 | 118 | .popover { 119 | --top: auto; 120 | --left: auto; 121 | --transform: none; 122 | 123 | pointer-events: auto; 124 | position: absolute; 125 | outline: none; 126 | top: var(--top); 127 | left: var(--left); 128 | transform: var(--transform); 129 | margin-top: 1rem; 130 | background: gray; 131 | border-radius: 0.25rem; 132 | padding: 0.125rem; 133 | 134 | &:after { 135 | content: ''; 136 | position: absolute; 137 | bottom: 100%; 138 | left: calc(50% - 0.25rem); 139 | width: 0.5rem; 140 | height: 0.4rem; 141 | background: gray; 142 | clip-path: polygon(0% 100%, 100% 100%, 50% 0%); 143 | } 144 | 145 | &--arrow, 146 | &--marker { 147 | display: flex; 148 | flex-direction: column; 149 | justify-content: stretch; 150 | } 151 | } 152 | 153 | .color-button { 154 | --color: red; 155 | width: 1.25rem; 156 | height: 1.25rem; 157 | margin: 0.125rem; 158 | padding: 0; 159 | border: 0; 160 | background: var(--color); 161 | border-radius: 0.125rem; 162 | position: relative; 163 | 164 | &--selected { 165 | &:after { 166 | content: ''; 167 | position: absolute; 168 | top: 0; 169 | left: 0; 170 | width: 100%; 171 | height: 100%; 172 | background: radial-gradient( 173 | circle at center, 174 | white 0%, 175 | white 25%, 176 | rgba(0, 0, 0, 0) 25% 177 | ); 178 | } 179 | } 180 | } 181 | 182 | .color-picker { 183 | display: grid; 184 | grid-auto-flow: column; 185 | grid-template-rows: repeat(4, auto); 186 | } 187 | 188 | .home-page { 189 | main { 190 | width: 50rem; 191 | max-width: 95%; 192 | margin: 0 auto; 193 | 194 | .file-form { 195 | &__input-row { 196 | display: flex; 197 | flex-direction: row; 198 | 199 | input { 200 | flex-grow: 1; 201 | flex-shrink: 1; 202 | } 203 | } 204 | 205 | [role='alert'] { 206 | border: 0.125rem solid red; 207 | background: rgb(255, 245, 245); 208 | padding: 1rem; 209 | margin-top: 2rem; 210 | 211 | .permalink-diagram { 212 | margin-top: 3rem; 213 | display: flex; 214 | flex-direction: column; 215 | align-items: center; 216 | } 217 | } 218 | } 219 | 220 | .trimmable-url { 221 | white-space: nowrap; 222 | text-overflow: ellipsis; 223 | max-width: 100%; 224 | overflow: hidden; 225 | } 226 | } 227 | } 228 | 229 | .home-page, 230 | .annotation-page { 231 | display: flex; 232 | flex-direction: column; 233 | min-height: calc(100vh - 4rem); 234 | 235 | main { 236 | flex-grow: 1; 237 | } 238 | 239 | footer { 240 | display: flex; 241 | flex-direction: column; 242 | align-items: center; 243 | padding-top: 1rem; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/hooks/useArrowDrawing.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { v4 as uuid } from 'uuid' 3 | import { distanceBetweenPoints, pointOnPolylineNearPoint } from '../geometry' 4 | import { addArrow } from '../reducer' 5 | import { useDispatch } from '../store' 6 | import { Arrow, Marker, Point, UnfinishedArrow } from '../types' 7 | import { useContainer } from './useContainer' 8 | import useKeyboardHandler from './useKeyboardHandler' 9 | import { ArrowDrawingMode, useSettings } from './useSettings' 10 | 11 | // TYPES 12 | 13 | type EventHandlers = { 14 | onClick: (event: React.MouseEvent, target: Marker | Arrow | null) => void 15 | onMouseMove: (event: React.MouseEvent, target: Marker | null) => void 16 | cancelArrow: () => void 17 | } 18 | 19 | // CONTEXTS 20 | 21 | const CurrentArrowContext = React.createContext< 22 | UnfinishedArrow | null | undefined 23 | >(undefined) 24 | CurrentArrowContext.displayName = 'CurrentArrowContext' 25 | 26 | const EventHandlersContext = React.createContext( 27 | undefined, 28 | ) 29 | EventHandlersContext.displayName = 'EventHandlersContext' 30 | 31 | // PROVIDER 32 | 33 | export function ArrowDrawingProvider({ 34 | children, 35 | }: React.PropsWithChildren<{}>) { 36 | const { eventCoordinates } = useContainer() 37 | const [currentArrow, setCurrentArrow] = 38 | React.useState(null) 39 | const { arrowDrawingMode } = useSettings() 40 | const dispatch = useDispatch() 41 | 42 | const escapeKeyHandler = React.useCallback((event: KeyboardEvent) => { 43 | if (event.key === 'Escape') { 44 | setCurrentArrow(null) 45 | } 46 | }, []) 47 | useKeyboardHandler(escapeKeyHandler) 48 | 49 | const onClick = useCallback( 50 | (event: React.MouseEvent, target: Marker | Arrow | null) => { 51 | if (event.button === 2) { 52 | return 53 | } 54 | event.stopPropagation() 55 | const currentPoint = eventCoordinates(event) 56 | if (!currentArrow) { 57 | if (target) { 58 | setCurrentArrow(newArrow(target, arrowDrawingMode, currentPoint)) 59 | } 60 | } else if (targetIsOrigin(target, currentArrow)) { 61 | setCurrentArrow(null) 62 | } else if (isMarker(target)) { 63 | dispatch(addArrow(finishedArrow(currentPoint, target, currentArrow))) 64 | setCurrentArrow(null) 65 | } else { 66 | setCurrentArrow({ 67 | ...currentArrow, 68 | midPoints: [...currentArrow.midPoints, currentPoint], 69 | }) 70 | } 71 | }, 72 | [eventCoordinates, currentArrow, arrowDrawingMode], 73 | ) 74 | 75 | const onMouseMove = useCallback( 76 | (event: React.MouseEvent, target: Marker | null) => { 77 | if (!currentArrow) { 78 | return 79 | } 80 | event.stopPropagation() 81 | const currentPoint = eventCoordinates(event) 82 | 83 | const addCurrentPointAsMidPoint = 84 | currentArrow.drawingMode === 'freehand' && 85 | shouldAddCurrentPointToFreehandArrow(currentPoint, currentArrow) 86 | const midPoints = addCurrentPointAsMidPoint 87 | ? [...currentArrow.midPoints, currentPoint] 88 | : currentArrow.midPoints 89 | const toMarker = targetIsOrigin(target, currentArrow) 90 | ? null 91 | : target?.id ?? null 92 | 93 | setCurrentArrow({ 94 | ...currentArrow, 95 | midPoints, 96 | toPoint: currentPoint, 97 | toMarker, 98 | }) 99 | }, 100 | [currentArrow, eventCoordinates], 101 | ) 102 | 103 | const cancelArrow = React.useCallback(() => { 104 | setCurrentArrow(null) 105 | }, []) 106 | 107 | const handlers: EventHandlers = React.useMemo( 108 | () => ({ 109 | onClick, 110 | onMouseMove, 111 | cancelArrow, 112 | }), 113 | [onClick, onMouseMove, cancelArrow], 114 | ) 115 | 116 | return ( 117 | 118 | 119 | {children} 120 | 121 | 122 | ) 123 | } 124 | 125 | // HOOKS 126 | 127 | export function useDrawingEventHandlers(): EventHandlers { 128 | const handlers = React.useContext(EventHandlersContext) 129 | if (!handlers) { 130 | throw new Error( 131 | `Tried to call useDrawingEventHandlers outside of an `, 132 | ) 133 | } 134 | 135 | return handlers 136 | } 137 | 138 | export function useCurrentArrowDrawing(): UnfinishedArrow | null { 139 | const currentArrow = React.useContext(CurrentArrowContext) 140 | if (currentArrow === undefined) { 141 | throw new Error( 142 | `Tried to call useCurrentArrowDrawing outside of an `, 143 | ) 144 | } 145 | 146 | return currentArrow 147 | } 148 | 149 | // UTILITIES 150 | 151 | function newArrow( 152 | target: Arrow | Marker, 153 | drawingMode: ArrowDrawingMode, 154 | currentPoint: Point, 155 | ): UnfinishedArrow { 156 | if (isArrow(target)) { 157 | const fromPoint = pointOnPolylineNearPoint(currentPoint, [ 158 | target.fromPoint, 159 | ...target.midPoints, 160 | target.toPoint, 161 | ]) 162 | const dependencies = { 163 | ...target.dependencies, 164 | [target.id]: true, 165 | [target.fromMarker]: true, 166 | } 167 | return { 168 | drawingMode, 169 | fromPoint, 170 | fromMarker: target.fromMarker, 171 | fromArrow: target.id, 172 | midPoints: [], 173 | toPoint: currentPoint, 174 | toMarker: null, 175 | dependencies, 176 | } 177 | } else { 178 | return { 179 | drawingMode, 180 | fromPoint: currentPoint, 181 | fromMarker: target.id, 182 | fromArrow: null, 183 | midPoints: [], 184 | toPoint: currentPoint, 185 | toMarker: null, 186 | dependencies: { [target.id]: true }, 187 | } 188 | } 189 | } 190 | 191 | function finishedArrow( 192 | currentPoint: Point, 193 | target: Marker, 194 | currentArrow: UnfinishedArrow, 195 | ): Arrow { 196 | return { 197 | fromMarker: currentArrow.fromMarker, 198 | fromPoint: currentArrow.fromPoint, 199 | midPoints: currentArrow.midPoints, 200 | toMarker: target.id, 201 | toPoint: currentPoint, 202 | id: uuid(), 203 | dependencies: { ...currentArrow.dependencies, [target.id]: true }, 204 | } 205 | } 206 | 207 | function shouldAddCurrentPointToFreehandArrow( 208 | currentPoint: Point, 209 | currentArrow: UnfinishedArrow, 210 | ) { 211 | const lastPoint = 212 | currentArrow.midPoints[currentArrow.midPoints.length - 1] ?? 213 | currentArrow.fromPoint 214 | return distanceBetweenPoints(currentPoint, lastPoint) > 10 215 | } 216 | 217 | function isArrow(target: Arrow | Marker | null): target is Arrow { 218 | return target !== null && 'fromMarker' in target 219 | } 220 | 221 | function isMarker(target: Arrow | Marker | null): target is Marker { 222 | return target !== null && !('fromMarker' in target) 223 | } 224 | 225 | function targetIsOrigin( 226 | target: Marker | Arrow | null, 227 | currentArrow: UnfinishedArrow, 228 | ): boolean { 229 | if (!target) { 230 | return false 231 | } 232 | return ( 233 | target.id === currentArrow.fromArrow || 234 | target.id === currentArrow.fromMarker 235 | ) 236 | } 237 | --------------------------------------------------------------------------------