├── .gitignore
├── .idea
├── .gitignore
├── codeStyles
│ └── codeStyleConfig.xml
├── graphite.iml
├── inspectionProfiles
│ └── Project_Default.xml
├── misc.xml
├── modules.xml
├── php.xml
└── vcs.xml
├── .prettierrc.js
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── public
├── icon16.png
├── icon192.png
├── icon512.png
├── index.html
├── manifest.json
└── robots.txt
├── src
├── App.tsx
├── Nav.tsx
├── components
│ ├── Dropzone.tsx
│ ├── Window.tsx
│ ├── inputs
│ │ └── ColorPicker.tsx
│ └── toolbar
│ │ ├── ScaleSelect.tsx
│ │ ├── SelectionToolbar.tsx
│ │ ├── Settings.tsx
│ │ ├── ShadowSelector.tsx
│ │ └── Toolbar.tsx
├── hooks.ts
├── icons
│ ├── add.svg
│ ├── cog.svg
│ ├── cross.svg
│ ├── direction.svg
│ └── download.svg
├── index.css
├── index.js
├── react-app-env.d.ts
├── serviceWorker.js
├── store
│ ├── index.ts
│ ├── reducer.ts
│ └── windows.ts
└── utils
│ └── image.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Datasource local storage ignored files
5 | /dataSources/
6 | /dataSources.local.xml
7 | # Editor-based HTTP Client requests
8 | /httpRequests/
9 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/graphite.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
15 |
16 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/php.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | trailingComma: "es5",
3 | tabWidth: 4,
4 | semi: true,
5 | useTabs: true,
6 | bracketSpacing: false,
7 | jsxBracketSameLine: true,
8 | };
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Simone Masiero
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Graphite Shot
2 |
3 | #### Motivation
4 |
5 | There are quite a few tools to create nice looking images from code. Some examples that inspired this tool are:
6 | - [Carbon](https://carbon.now.sh)
7 | - [Polacode](https://marketplace.visualstudio.com/items?itemName=pnp.polacode)
8 | - [Codeimg](https://codeimg.io/)
9 |
10 | But all of these require that you actually write the code in them. I wanted something where I could write my code in the editor and just add the fancy window to the screenshot.
11 | Graphite Shot lets you do just that.
12 |
13 | #### Features
14 |
15 | - Drop, Paste or use dialog to select your image.
16 | - Add windows horizontally and vertically
17 | - Automatically choose the window color based on the most common color of the image
18 | - Change window and background colors
19 |
20 | #### Dev
21 |
22 | React App with Typescript and Styled-Components.
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "graphite-shot",
3 | "version": "0.1.2",
4 | "license": "MIT",
5 | "devDependencies": {
6 | "@types/file-saver": "^2.0.3",
7 | "@types/node": "^16.10.2",
8 | "@types/react-color": "^3.0.5",
9 | "@types/react-redux": "^7.1.18",
10 | "@types/styled-components": "^5.1.14",
11 | "@types/uuid": "^8.3.1",
12 | "@types/webpack-env": "^1.16.2",
13 | "prettier": "^2.4.1",
14 | "react-scripts": "4.0.3",
15 | "typescript": "^4.4.3"
16 | },
17 | "dependencies": {
18 | "@reduxjs/toolkit": "^1.6.1",
19 | "file-saver": "^2.0.5",
20 | "html-to-image": "^1.8.5",
21 | "jimp": "^0.16.1",
22 | "react": "^17.0.2",
23 | "react-color": "^2.19.3",
24 | "react-dom": "^17.0.2",
25 | "react-dropzone": "^11.4.2",
26 | "react-is": "^17.0.2",
27 | "react-redux": "^7.2.5",
28 | "react-shadow-picker": "^1.0.6",
29 | "react-tippy": "^1.4.0",
30 | "redux": "^4.1.1",
31 | "styled-components": "^5.3.1",
32 | "uuid": "^8.3.2"
33 | },
34 | "scripts": {
35 | "start": "react-scripts start",
36 | "build": "react-scripts build",
37 | "test": "react-scripts test",
38 | "eject": "react-scripts eject",
39 | "checkfmt": "prettier -l \"**/*.{js,json,jsx,ts,tsx}\"",
40 | "fmt": "prettier --write \"**/*.{js,json,jsx,ts,tsx}\""
41 | },
42 | "eslintConfig": {
43 | "extends": "react-app"
44 | },
45 | "browserslist": {
46 | "production": [
47 | ">0.2%",
48 | "not dead",
49 | "not op_mini all"
50 | ],
51 | "development": [
52 | "last 1 chrome version",
53 | "last 1 firefox version",
54 | "last 1 safari version"
55 | ]
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/public/icon16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/duiker101/graphite-shot/3357ab3cd5070864a42c1130d9a8f28a275b317f/public/icon16.png
--------------------------------------------------------------------------------
/public/icon192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/duiker101/graphite-shot/3357ab3cd5070864a42c1130d9a8f28a275b317f/public/icon192.png
--------------------------------------------------------------------------------
/public/icon512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/duiker101/graphite-shot/3357ab3cd5070864a42c1130d9a8f28a275b317f/public/icon512.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | Graphite
28 |
29 |
30 |
31 |
32 |
42 |
43 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Graphite",
3 | "name": "Graphite allows you create nice images from your screenshots",
4 | "icons": [
5 | {
6 | "src": "icon16.png",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "icon192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "icon512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, {useRef, useState} from "react";
2 | import styled from "styled-components";
3 | import Toolbar from "./components/toolbar/Toolbar";
4 | import Window from "./components/Window";
5 | import {useWindows} from "./store/windows";
6 | import Nav from "./Nav";
7 |
8 | const Wrapper = styled.div`
9 | display: flex;
10 | flex-direction: column;
11 | min-height: 100vh;
12 | height: 100vh;
13 | `;
14 |
15 | const Main = styled.div`
16 | flex: 1;
17 | min-width: 200px;
18 | display: flex;
19 | flex-direction: column;
20 | align-items: center;
21 | justify-content: center;
22 | min-width: min-content;
23 | `;
24 |
25 | /**
26 | * This border element is needed so that it will not be saved in the image
27 | * and the result will not have rounded corners
28 | */
29 | const Border = styled.div`
30 | border: 2px solid white;
31 | border-radius: 4px;
32 | `;
33 |
34 | const Content = styled.div<{bg: string; horizontal: boolean}>`
35 | margin: auto;
36 | padding: 10px;
37 | background: ${p => p.bg};
38 | padding: 64px;
39 | display: grid;
40 | grid-auto-flow: ${({horizontal}) => (horizontal ? "column" : "row")};
41 | grid-gap: 64px;
42 | align-items: center;
43 | justify-items: center;
44 | `;
45 |
46 | export default () => {
47 | const [bgColor, setBgColor] = useState("cadetblue");
48 | const contentRef = useRef(null);
49 | const windows = useWindows();
50 | const [horizontal, setIsHorizontal] = useState(true);
51 |
52 | return (
53 |
54 |
55 |
56 | setIsHorizontal(d)}
58 | horizontal={horizontal}
59 | bgColor={bgColor}
60 | onBgColor={setBgColor}
61 | content={contentRef.current}
62 | />
63 |
64 |
65 |
69 | {Object.entries(windows).map(([id, w]) => (
70 |
71 | ))}
72 |
73 |
74 |
75 |
76 |
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/src/Nav.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 |
4 | const Wrapper = styled.div`
5 | display: flex;
6 | justify-content: center;
7 | align-items: center;
8 | height: 50px;
9 | `;
10 |
11 | const Section = styled.div`
12 | margin: 0 1ch;
13 | color: #aaa;
14 | `;
15 |
16 | const Link = styled.a`
17 | color: teal;
18 | &:visited {
19 | color: teal;
20 | }
21 | `;
22 |
23 | export default () => {
24 | return (
25 |
26 |
27 | Made By{" "}
28 | @Duiker101
29 |
30 |
31 |
32 | GitHub
33 |
34 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/src/components/Dropzone.tsx:
--------------------------------------------------------------------------------
1 | import React, {PropsWithChildren, useCallback} from "react";
2 | import styled from "styled-components";
3 | import {useDropzone} from "react-dropzone";
4 |
5 | const Wrapper = styled.div`
6 | position: relative;
7 | flex-direction: column;
8 | display: flex;
9 | flex: 1;
10 | `;
11 |
12 | const Placeholder = styled.div`
13 | border: 1px dashed white;
14 | border-radius: 4px;
15 | flex: 1;
16 | align-items: center;
17 | justify-content: center;
18 | display: flex;
19 | padding: 32px;
20 | `;
21 |
22 | const Cover = styled.div`
23 | position: absolute;
24 | top: 0;
25 | bottom: 0;
26 | left: 0;
27 | right: 0;
28 | background: hsla(218, 50%, 50%, 0.5);
29 | `;
30 |
31 | interface Props {
32 | onImage: (image: string) => void;
33 | hasImage: boolean;
34 | }
35 |
36 | export default ({children, onImage, hasImage}: PropsWithChildren) => {
37 | const onDrop = useCallback(
38 | acceptedFiles => {
39 | for (let file of acceptedFiles) {
40 | const reader = new FileReader();
41 | reader.addEventListener(
42 | "load",
43 | () => {
44 | onImage(reader.result as string);
45 | },
46 | false
47 | );
48 | reader.readAsDataURL(file);
49 | return;
50 | }
51 | },
52 | [onImage]
53 | );
54 |
55 | const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop});
56 |
57 | return (
58 |
59 |
60 | {children}
61 | {!hasImage && (
62 | Drop or paste an image here.
63 | )}
64 | {isDragActive && }
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/src/components/Window.tsx:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useState} from "react";
2 | import styled from "styled-components";
3 | import Dropzone from "./Dropzone";
4 | import {usePastedImage} from "../hooks";
5 | import {useDispatch} from "react-redux";
6 | import {processImage} from "../utils/image";
7 | import Jimp from "jimp";
8 | import {
9 | selectWindow,
10 | setWindowColor,
11 | setWindowImage,
12 | useWindow,
13 | } from "../store/windows";
14 | import {AppDispatch} from "../store";
15 |
16 | const Wrapper = styled.div<{bg: string; shadow: string}>`
17 | background: ${p => p.bg};
18 | border-radius: 10px;
19 | box-shadow: ${p => p.shadow};
20 | min-height: 20px;
21 | padding: 8px;
22 | display: flex;
23 | flex-direction: column;
24 | border: 1px solid transparent;
25 | cursor: pointer;
26 | &:hover {
27 | border: 1px solid white;
28 | }
29 | `;
30 |
31 | const Header = styled.div`
32 | display: flex;
33 | margin-bottom: 8px;
34 | `;
35 |
36 | const Image = styled.img<{width?: number; height?: number}>`
37 | width: ${p => (p.width ? p.width + "px" : "auto")};
38 | height: ${p => (p.height ? p.height + "px" : "auto")};
39 | `;
40 |
41 | const Dot = styled.div<{color: string}>`
42 | background: ${p => p.color};
43 | width: 12px;
44 | height: 12px;
45 | border-radius: 50%;
46 | margin: 4px;
47 | `;
48 |
49 | interface Props {
50 | windowId: string;
51 | }
52 |
53 | export default ({windowId}: Props) => {
54 | const [srcImage, setSrcImage] = usePastedImage();
55 | const dispatch: AppDispatch = useDispatch();
56 | const {color: bgColor, image: imageData, scaling, shadow} = useWindow(
57 | windowId
58 | );
59 | const [size, setSize] = useState<{
60 | width?: number;
61 | height?: number;
62 | }>({width: undefined, height: undefined});
63 |
64 | useEffect(() => {
65 | if (!srcImage) return;
66 |
67 | processImage(srcImage).then(([color, image]) => {
68 | setSize({width: image.getWidth(), height: image.getHeight()});
69 | image.getBase64Async(Jimp.MIME_PNG).then(base => {
70 | dispatch(setWindowImage({id: windowId, image: base}));
71 | });
72 | dispatch(setWindowColor({id: windowId, color}));
73 | });
74 | }, [srcImage, dispatch, windowId]);
75 |
76 | const imgWidth = size.width ? size.width * scaling : undefined;
77 | const imgHeight = size.height ? size.height * scaling : undefined;
78 |
79 | const selected = () => {
80 | dispatch(selectWindow(windowId));
81 | };
82 | return (
83 |
84 |
89 | setSrcImage(image)}>
92 | {imageData && (
93 |
98 | )}
99 |
100 |
101 | );
102 | };
103 |
--------------------------------------------------------------------------------
/src/components/inputs/ColorPicker.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from "react";
2 | import styled from "styled-components";
3 | import {ChromePicker, ColorResult} from "react-color";
4 |
5 | const Wrapper = styled.div`
6 | position: relative;
7 | `;
8 |
9 | const Button = styled.div<{color: string}>`
10 | background: ${p => p.color};
11 | border-radius: 2px;
12 | border: 1px solid white;
13 | height: 30px;
14 | width: 30px;
15 | margin: 5px;
16 | cursor: pointer;
17 | &:hover {
18 | filter: brightness(1.4);
19 | }
20 | `;
21 |
22 | const Popover = styled.div`
23 | position: absolute;
24 | z-index: 2;
25 | `;
26 |
27 | const Cover = styled.div`
28 | position: fixed;
29 | top: 0;
30 | bottom: 0;
31 | left: 0;
32 | right: 0;
33 | `;
34 |
35 | const Palette = styled.div`
36 | display: flex;
37 | width: 225px;
38 | background: white;
39 | padding: 4px 4px;
40 | box-sizing: border-box;
41 | `;
42 |
43 | const Item = styled.div<{color: string}>`
44 | width: 12px;
45 | height: 12px;
46 | background: ${p => p.color};
47 | border-radius: 2px;
48 | margin: 4px;
49 | box-shadow: inset 0px 0px 2px rgba(0, 0, 0, 0.2);
50 | cursor: pointer;
51 | `;
52 |
53 | interface Props {
54 | onChange: (color: string) => void;
55 | color: string;
56 | palette?: string[];
57 | }
58 |
59 | export default ({color, onChange, palette}: Props) => {
60 | const [open, setOpen] = useState(false);
61 |
62 | const onColorChange = (result: ColorResult) => {
63 | const {r, g, b, a} = result.rgb;
64 | onChange(`rgba(${r},${g},${b},${a})`);
65 | };
66 |
67 | return (
68 |
69 |
90 | );
91 | };
92 |
--------------------------------------------------------------------------------
/src/components/toolbar/ScaleSelect.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {useDispatch} from "react-redux";
3 | import {setWindowScaling, useSelectedWindow} from "../../store/windows";
4 |
5 | export default () => {
6 | const dispatch = useDispatch();
7 | const selected = useSelectedWindow();
8 |
9 | const scales = [0.5, 1, 2];
10 |
11 | const onScalingChange = (e: React.ChangeEvent) => {
12 | dispatch(
13 | setWindowScaling({
14 | id: selected.id,
15 | scale: parseFloat(e.target.value),
16 | })
17 | );
18 | };
19 |
20 | return (
21 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/toolbar/SelectionToolbar.tsx:
--------------------------------------------------------------------------------
1 | import React, {useMemo} from "react";
2 | import styled from "styled-components";
3 | import {useDispatch} from "react-redux";
4 | import ColorPicker from "../inputs/ColorPicker";
5 | import {removeWindow, setWindowColor, useSelectedWindow, useWindows,} from "../../store/windows";
6 | import ScalingSelect from "./ScaleSelect";
7 | import {ReactComponent as CrossImg} from "../../icons/cross.svg";
8 | import ShadowSelector from "./ShadowSelector";
9 |
10 | const Remove = styled.div`
11 | border: 1px solid transparent;
12 | border-radius: 4px;
13 | height: 20px;
14 | width: 20px;
15 | user-select: none;
16 | color: white;
17 | cursor: pointer;
18 | margin-left: 0.8ch;
19 |
20 | svg {
21 | height: 100%;
22 | width: 100%;
23 | }
24 |
25 | &:hover {
26 | background: rgba(200, 200, 200, 0.4);
27 | }
28 | `;
29 |
30 |
31 | export default () => {
32 | const dispatch = useDispatch();
33 | const selection = useSelectedWindow();
34 | const windows = useWindows();
35 |
36 | const palette = useMemo(() => {
37 | const windowColors = [
38 | ...new Set(Object.entries(windows).map(([id, w]) => w.color)),
39 | ];
40 | return [
41 | "#3D7BC7",
42 | "#17826D",
43 | "#F7EBD1",
44 | "#DFAC5D",
45 | "#44B87E",
46 | ...windowColors,
47 | ];
48 | }, [windows]);
49 |
50 | return (
51 | <>
52 |
55 | dispatch(setWindowColor({id: selection.id, color: c}))
56 | }
57 | palette={palette}
58 | />
59 |
60 |
61 |
62 |
63 | {Object.values(windows).length > 1 && (
64 | dispatch(removeWindow(selection.id))}>
65 |
66 |
67 | )}
68 | >
69 | );
70 | };
71 |
--------------------------------------------------------------------------------
/src/components/toolbar/Settings.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import {useDispatch} from "react-redux"
4 |
5 | const Wrapper = styled.div`
6 | `
7 |
8 | export default () => {
9 | const dispatch = useDispatch();
10 | return (
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/toolbar/ShadowSelector.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import {useDispatch} from "react-redux";
4 | import {Tooltip} from "react-tippy";
5 | import {ShadowPicker} from "react-shadow-picker";
6 | import {AppDispatch} from "../../store";
7 | import {useSelectedWindow, setWindowShadow} from "../../store/windows";
8 |
9 | const Button = styled.div`
10 | border-radius: 50%;
11 | border: 1px solid white;
12 | box-shadow: 1px 1px 3px 0px #000f;
13 | height: 20px;
14 | width: 20px;
15 | margin: 5px;
16 | user-select: none;
17 | color: white;
18 | cursor: pointer;
19 | box-sizing: border-box;
20 | padding: 3px;
21 | &:hover {
22 | background: rgba(200, 200, 200, 0.1);
23 | box-shadow: 1px 1px 3px 0px #ffff;
24 | }
25 | `;
26 |
27 | const TooltipBg = styled.div`
28 | padding: 0.3em;
29 | background: #2A2A2A;
30 | border:1px solid white;
31 | `;
32 |
33 | export default () => {
34 | const dispatch: AppDispatch = useDispatch();
35 | const selection = useSelectedWindow();
36 |
37 | const update = (shadow: string) => {
38 | dispatch(setWindowShadow({id: selection.id, shadow}));
39 | };
40 |
41 | return (
42 |
48 |
49 |
50 | }>
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/src/components/toolbar/Toolbar.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import ColorPicker from "../inputs/ColorPicker";
4 | import downloadImg from "../../icons/download.svg";
5 | import addImg from "../../icons/add.svg";
6 | import directionImg from "../../icons/direction.svg";
7 | import {ReactComponent as CogImg} from "../../icons/cog.svg";
8 | import {toPng} from "html-to-image";
9 | import {saveAs} from "file-saver";
10 | import {useDispatch} from "react-redux";
11 | import {addWindow, useWindows} from "../../store/windows";
12 | import {AppDispatch} from "../../store";
13 | import SelectionToolbar from "./SelectionToolbar";
14 | import Settings from "./Settings";
15 |
16 | const Wrapper = styled.div``;
17 |
18 | const TopBar = styled.div`
19 | display: flex;
20 | justify-content: space-between;
21 | `;
22 |
23 | const Pickers = styled.div`
24 | display: flex;
25 | align-items: center;
26 | `;
27 |
28 | const Divider = styled.div`
29 | width: 1px;
30 | height: 10px;
31 | background: white;
32 | `;
33 |
34 | const Button = styled.div`
35 | border-radius: 2px;
36 | border: 1px solid white;
37 | height: 32px;
38 | width: 32px;
39 | margin: 5px;
40 | user-select: none;
41 | color: white;
42 | cursor: pointer;
43 | box-sizing: border-box;
44 | padding: 3px;
45 | &:hover {
46 | background: rgba(200, 200, 200, 0.4);
47 | }
48 | `;
49 |
50 | const DirectionButton = styled(Button)<{horizontal: boolean}>`
51 | img {
52 | transition: transform 200ms ease-in-out;
53 | ${({horizontal}) =>
54 | horizontal &&
55 | `
56 | transform: rotate(90deg);
57 | `}
58 | }
59 | `;
60 |
61 | interface Props {
62 | bgColor: string;
63 | onBgColor: (color: string) => void;
64 | onDirection: (horizontal: boolean) => void;
65 | horizontal: boolean;
66 | content: HTMLDivElement | null;
67 | }
68 |
69 | export default ({
70 | bgColor,
71 | onBgColor,
72 | content,
73 | horizontal,
74 | onDirection,
75 | }: Props) => {
76 | const dispatch: AppDispatch = useDispatch();
77 | const windows = useWindows();
78 |
79 | const save = () => {
80 | if (!content) return;
81 | toPng(content)
82 | .then((dataUrl) => {
83 | saveAs(dataUrl, "graphite.png");
84 | })
85 | .catch((error) => {
86 | console.error("oops, something went wrong!", error);
87 | });
88 | };
89 |
90 | return (
91 |
92 |
93 |
94 |
97 |
108 |
109 | {Object.entries(windows).length > 1 && (
110 | onDirection(!horizontal)}>
113 |
114 |
115 | )}
116 |
119 |
120 |
121 |
122 |
123 |
126 |
127 |
128 |
129 |
130 | );
131 | };
132 |
--------------------------------------------------------------------------------
/src/hooks.ts:
--------------------------------------------------------------------------------
1 | import {Dispatch, SetStateAction, useEffect, useState} from "react";
2 |
3 | export const usePastedImage = (): [
4 | string | undefined,
5 | Dispatch>
6 | ] => {
7 | const [imageData, setImageData] = useState();
8 | useEffect(() => {
9 | const listener = (e: Event) => {
10 | const {clipboardData: data} = e as ClipboardEvent;
11 | const items = data?.items || [];
12 |
13 | for (let item of items) {
14 | if (item.type.indexOf("image") === -1) continue;
15 | const blob = item.getAsFile();
16 | let URLObj = window.URL || window.webkitURL;
17 | setImageData(URLObj.createObjectURL(blob));
18 | return;
19 | }
20 | };
21 |
22 | window.addEventListener("paste", listener);
23 | return () => window.removeEventListener("paste", listener);
24 | }, []);
25 |
26 | return [imageData, setImageData];
27 | };
28 |
--------------------------------------------------------------------------------
/src/icons/add.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/icons/cog.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/icons/cross.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/icons/direction.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/icons/download.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
4 | "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
5 | "Helvetica Neue", sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | background: #343434;
9 | padding: 0;
10 | margin: 0;
11 | color: whitesmoke;
12 | height: 100vh;
13 | }
14 |
15 | #root {
16 | min-width: min-content;
17 | min-height: 100vh;
18 | height: 100vh;
19 | }
20 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import "./index.css";
4 | import App from "./App";
5 | import * as serviceWorker from "./serviceWorker";
6 |
7 | import {Provider} from "react-redux";
8 | import store from "./store";
9 |
10 | ReactDOM.render(
11 |
12 |
13 |
14 |
15 | ,
16 | document.getElementById("root")
17 | );
18 |
19 | // If you want your app to work offline and load faster, you can change
20 | // unregister() to register() below. Note this comes with some pitfalls.
21 | // Learn more about service workers: https://bit.ly/CRA-PWA
22 | serviceWorker.unregister();
23 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === "localhost" ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === "[::1]" ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener("load", () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | "This web app is being served cache-first by a service " +
46 | "worker. To learn more, visit https://bit.ly/CRA-PWA"
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === "installed") {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | "New content is available and will be used when all " +
74 | "tabs for this page are closed. See https://bit.ly/CRA-PWA."
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log("Content is cached for offline use.");
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error("Error during service worker registration:", error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: {"Service-Worker": "script"},
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get("content-type");
109 | if (
110 | response.status === 404 ||
111 | (contentType != null &&
112 | contentType.indexOf("javascript") === -1)
113 | ) {
114 | // No service worker found. Probably a different app. Reload the page.
115 | navigator.serviceWorker.ready.then(registration => {
116 | registration.unregister().then(() => {
117 | window.location.reload();
118 | });
119 | });
120 | } else {
121 | // Service worker found. Proceed as normal.
122 | registerValidSW(swUrl, config);
123 | }
124 | })
125 | .catch(() => {
126 | console.log(
127 | "No internet connection found. App is running in offline mode."
128 | );
129 | });
130 | }
131 |
132 | export function unregister() {
133 | if ("serviceWorker" in navigator) {
134 | navigator.serviceWorker.ready
135 | .then(registration => {
136 | registration.unregister();
137 | })
138 | .catch(error => {
139 | console.error(error.message);
140 | });
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import {Action, configureStore, ThunkAction} from "@reduxjs/toolkit";
2 |
3 | import rootReducer, {RootState} from "./reducer";
4 |
5 | const store = configureStore({
6 | reducer: rootReducer,
7 | });
8 |
9 | if (process.env.NODE_ENV === "development" && module.hot) {
10 | module.hot.accept("./reducer", () => {
11 | const newRootReducer = require("./reducer").default;
12 | store.replaceReducer(newRootReducer);
13 | });
14 | }
15 |
16 | export type AppDispatch = typeof store.dispatch;
17 |
18 | export type AppThunk = ThunkAction>;
19 |
20 | export default store;
21 |
--------------------------------------------------------------------------------
/src/store/reducer.ts:
--------------------------------------------------------------------------------
1 | import {combineReducers} from "@reduxjs/toolkit";
2 | import windows from "./windows";
3 | import {useSelector} from "react-redux";
4 |
5 | const rootReducer = combineReducers({windows});
6 |
7 | export type RootState = ReturnType;
8 |
9 | export default rootReducer;
10 |
11 | export function useRootState(selector: (state: RootState) => any) {
12 | return useSelector((s: RootState) => selector(s));
13 | }
14 |
--------------------------------------------------------------------------------
/src/store/windows.ts:
--------------------------------------------------------------------------------
1 | import {createSlice, PayloadAction} from "@reduxjs/toolkit";
2 | import {useSelector} from "react-redux";
3 | import {RootState} from "./reducer";
4 |
5 | interface CodeWindow {
6 | color: string;
7 | image?: string;
8 | scaling: number;
9 | shadow?: string;
10 | id: string;
11 | }
12 |
13 | let windowIdIndex = 0;
14 |
15 | export interface WindowsState {
16 | windows: {[id: string]: CodeWindow};
17 | selected: string;
18 | }
19 |
20 | const initialState: WindowsState = {
21 | windows: {
22 | "0": {
23 | color: "#263238",
24 | shadow: "10px 10px 20px #00000066",
25 | id: "0",
26 | scaling: 1 / window.devicePixelRatio,
27 | },
28 | },
29 | selected: "0",
30 | };
31 |
32 | const slice = createSlice({
33 | name: "windows",
34 | initialState,
35 | reducers: {
36 | addWindow(state) {
37 | const newId = (++windowIdIndex).toString();
38 | state.windows[newId] = {
39 | id: newId,
40 | color: "#263238",
41 | shadow: "10px 10px 20px #00000066",
42 | scaling: 1 / window.devicePixelRatio,
43 | };
44 | state.selected = newId;
45 | },
46 | removeWindow(state, action: PayloadAction) {
47 | delete state.windows[action.payload];
48 | state.selected = Object.values(state.windows)[0].id;
49 | },
50 | setWindowImage(
51 | state,
52 | {payload: {id, image}}: PayloadAction<{id: string; image: string}>
53 | ) {
54 | state.windows[id].image = image;
55 | },
56 | setWindowColor(
57 | state,
58 | {payload: {id, color}}: PayloadAction<{id: string; color: string}>
59 | ) {
60 | state.windows[id].color = color;
61 | },
62 | setWindowShadow(
63 | state,
64 | {payload: {id, shadow}}: PayloadAction<{id: string; shadow: string}>
65 | ) {
66 | state.windows[id].shadow = shadow;
67 | },
68 | setWindowScaling(
69 | state,
70 | {payload: {id, scale}}: PayloadAction<{id: string; scale: number}>
71 | ) {
72 | state.windows[id].scaling = scale;
73 | },
74 | selectWindow(state, action: PayloadAction) {
75 | state.selected = action.payload;
76 | },
77 | },
78 | });
79 |
80 | export const {
81 | addWindow,
82 | removeWindow,
83 | setWindowColor,
84 | setWindowImage,
85 | selectWindow,
86 | setWindowShadow,
87 | setWindowScaling,
88 | } = slice.actions;
89 |
90 | export default slice.reducer;
91 |
92 | export function useWindowState(selector: (state: WindowsState) => any) {
93 | return useSelector((s: RootState) => selector(s.windows));
94 | }
95 |
96 | export const useWindows = (): {[id: string]: CodeWindow} => {
97 | return useWindowState(s => s.windows);
98 | };
99 |
100 | export const useWindow = (id: string): CodeWindow => {
101 | return useWindowState(s => s.windows[id] ?? null);
102 | };
103 |
104 | export const useSelectedWindow = (): CodeWindow => {
105 | const {windows, selected} = useWindowState(s => s);
106 | return windows[selected];
107 | };
108 |
--------------------------------------------------------------------------------
/src/utils/image.ts:
--------------------------------------------------------------------------------
1 | import Jimp from "jimp";
2 |
3 | export const processImage = (imageData: string): Promise<[string, Jimp]> => {
4 | return Jimp.read(imageData).then(image => {
5 | const colors: {[key: string]: number} = {};
6 |
7 | for (let x = 0; x < image.getWidth(); x += 2) {
8 | for (let y = 0; y < image.getHeight(); y += 2) {
9 | const key = image.getPixelColour(x, y);
10 | colors[key] = colors[key] + 1 || 1;
11 | }
12 | }
13 |
14 | const color = Object.entries(colors).reduce(
15 | (a, [key, value]) => {
16 | if (value > a[1]) return [key, value];
17 | return a;
18 | },
19 | ["", 0]
20 | )[0];
21 |
22 | const {r, g, b} = Jimp.intToRGBA(parseInt(color));
23 | const hex = [r, g, b]
24 | .map(n => n.toString(16).padStart(2, "0"))
25 | .join("");
26 |
27 | return ["#" + hex, image];
28 | });
29 | };
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "downlevelIteration": true,
11 | "skipLibCheck": true,
12 | "esModuleInterop": true,
13 | "allowSyntheticDefaultImports": true,
14 | "strict": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx",
22 | "noFallthroughCasesInSwitch": true
23 | },
24 | "include": [
25 | "src"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------