├── .gitignore
├── .npmrc
├── README.md
├── dsr.config.ts
├── electron-builder.config.js
├── package.json
├── packages
├── backend
│ ├── dsb.config.ts
│ ├── package.json
│ ├── preload.d.ts
│ ├── src
│ │ ├── app.controller.ts
│ │ ├── app.module.ts
│ │ ├── app.service.ts
│ │ ├── main.ts
│ │ └── preload.ts
│ └── tsconfig.json
└── frontend
│ ├── .gitignore
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── public
│ └── favicon.ico
│ ├── src
│ ├── App.css
│ ├── App.tsx
│ ├── assets
│ │ ├── logo.png
│ │ ├── nestjs.svg
│ │ └── react.svg
│ ├── components
│ │ ├── Icons
│ │ │ ├── Clear.tsx
│ │ │ ├── Doc.tsx
│ │ │ ├── Github.tsx
│ │ │ ├── Save.tsx
│ │ │ └── index.ts
│ │ ├── Paint.css
│ │ └── Paint.tsx
│ ├── main.tsx
│ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── screenshot_react.png
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | shamefully-hoist=true
2 | node-linker=isolated
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | ## Template with React + Nestjs
8 |
9 | This template uses [React](https://react.dev/) and [Vite](https://vitejs.dev/) for the frontend part and [NestJS](https://nestjs.com/) in [Electron](https://www.electronjs.org/) for the backend (main process) part. And it's powered by [**DoubleShot**](https://github.com/Doubleshotjs/doubleshot).
10 |
11 | ## How to use
12 |
13 | It is recommended to use [pnpm](https://pnpm.io/) as the default package manager.
14 |
15 | - Install dependencies first:
16 |
17 | ```sh
18 | pnpm install
19 | ```
20 |
21 | - Run in development mode:
22 |
23 | ```sh
24 | pnpm dev
25 | ```
26 |
27 | - Build for production:
28 |
29 | ```sh
30 | pnpm run build
31 | ```
32 |
33 | ## Directory
34 | ```sh
35 | ├─┬ packages
36 | │ ├── backend # backend/main process part
37 | │ └── frontend # frontend/renderer process part
38 | ├── electron-builder.config.js # electron-builder config file
39 | ├── package.json
40 | └── dsr.config.ts # @doubleshot/runner config file
41 | ```
--------------------------------------------------------------------------------
/dsr.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "@doubleshot/runner"
2 |
3 | export default defineConfig({
4 | run: [
5 | {
6 | cwd: 'packages/frontend',
7 | name: 'renderer',
8 | prefixColor: 'blue',
9 | commands: {
10 | dev: 'npm run dev',
11 | build: 'npm run build'
12 | }
13 | },
14 | {
15 | cwd: 'packages/backend',
16 | name: 'electron',
17 | prefixColor: 'green',
18 | commands: {
19 | dev: {
20 | command: 'npm run dev',
21 | killOthersWhenExit: true
22 | },
23 | build: 'npm run build'
24 | }
25 | }
26 | ],
27 | electronBuild: {
28 | projectDir: 'packages/backend',
29 | commandName: 'build',
30 | config: 'electron-builder.config.js'
31 | }
32 | })
--------------------------------------------------------------------------------
/electron-builder.config.js:
--------------------------------------------------------------------------------
1 | const { join } = require('path')
2 | function resolve(path) {
3 | return join(__dirname, path)
4 | }
5 |
6 | /**
7 | * @type {import('electron-builder').Configuration}
8 | * @see https://www.electron.build/configuration/configuration
9 | */
10 | const config = {
11 | productName: 'Doubleshot App',
12 | directories: {
13 | output: resolve('dist'),
14 | },
15 | electronDownload: {
16 | mirror: 'https://npm.taobao.org/mirrors/electron/',
17 | },
18 | files: [
19 | resolve('packages/backend/package.json'),
20 | {
21 | from: resolve('packages/backend/dist'),
22 | to: 'backend',
23 | },
24 | {
25 | from: resolve('packages/frontend/dist'),
26 | to: 'frontend',
27 | },
28 | ]
29 | }
30 |
31 | module.exports = config
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "doubleshot-react-nest-starter",
3 | "private": true,
4 | "version": "0.0.0",
5 | "scripts": {
6 | "dev": "dsr dev",
7 | "build": "rimraf dist && dsr build"
8 | },
9 | "devDependencies": {
10 | "@doubleshot/runner": "^0.0.13"
11 | },
12 | "pnpm": {
13 | "onlyBuiltDependencies": [
14 | "@nestjs/core",
15 | "@swc/core",
16 | "electron",
17 | "esbuild"
18 | ]
19 | }
20 | }
--------------------------------------------------------------------------------
/packages/backend/dsb.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@doubleshot/builder'
2 |
3 | export default defineConfig({
4 | main: 'dist/main.js',
5 | entry: './src/main.ts',
6 | outDir: './dist',
7 | external: ['electron'],
8 | electron: {
9 | preload: {
10 | entry: './src/preload.ts'
11 | },
12 | rendererUrl: 'http://localhost:5173',
13 | waitTimeout: 5000,
14 | }
15 | })
16 |
--------------------------------------------------------------------------------
/packages/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "main": "backend/main.js",
6 | "scripts": {
7 | "dev": "dsb dev -t electron",
8 | "build": "dsb build -t electron"
9 | },
10 | "dependencies": {
11 | "@doubleshot/nest-electron": "0.2.6",
12 | "@nestjs/common": "^11.0.9",
13 | "@nestjs/core": "^11.0.9",
14 | "@nestjs/microservices": "^11.0.9",
15 | "reflect-metadata": "^0.2.2",
16 | "rxjs": "^7.8.1"
17 | },
18 | "devDependencies": {
19 | "@doubleshot/builder": "0.0.13",
20 | "@types/node": "22.13.2",
21 | "electron": "34.2.0",
22 | "electron-builder": "25.1.8",
23 | "typescript": "5.7.3"
24 | }
25 | }
--------------------------------------------------------------------------------
/packages/backend/preload.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | electron: {
4 | useZoomFactor(): { update: () => Promise }
5 | saveImageToFile(image: string): Promise
6 | },
7 | isElectron: boolean
8 | }
9 | }
10 |
11 | export { }
--------------------------------------------------------------------------------
/packages/backend/src/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller } from '@nestjs/common'
2 | import { IpcHandle } from '@doubleshot/nest-electron'
3 | import { AppService } from './app.service'
4 | import { Payload } from '@nestjs/microservices'
5 |
6 | @Controller()
7 | export class AppController {
8 | constructor(
9 | private readonly appService: AppService
10 | ) { }
11 |
12 | @IpcHandle('device-scale-factor')
13 | getDeviceScaleFactor() {
14 | return this.appService.getScaleFactor()
15 | }
16 |
17 | @IpcHandle('save-image')
18 | saveImage(@Payload() image: string) {
19 | return this.appService.saveImageToFile(image)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/backend/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path'
2 | import { Module } from '@nestjs/common'
3 | import { ElectronModule } from '@doubleshot/nest-electron'
4 | import { BrowserWindow, app } from 'electron'
5 | import { AppController } from './app.controller'
6 | import { AppService } from './app.service'
7 |
8 | @Module({
9 | imports: [ElectronModule.registerAsync({
10 | useFactory: async () => {
11 | const isDev = !app.isPackaged
12 | const win = new BrowserWindow({
13 | width: 1200,
14 | height: 800,
15 | autoHideMenuBar: true,
16 | webPreferences: {
17 | contextIsolation: true,
18 | preload: join(__dirname, 'preload.js')
19 | }
20 | })
21 |
22 | win.on('closed', () => {
23 | win.destroy()
24 | })
25 |
26 | const URL = isDev
27 | ? process.env.DS_RENDERER_URL
28 | : `file://${join(app.getAppPath(), 'frontend/index.html')}` // depends on electron-builder.config.js
29 |
30 | win.loadURL(URL)
31 |
32 | return { win }
33 | },
34 | })],
35 | controllers: [AppController],
36 | providers: [AppService],
37 | })
38 | export class AppModule { }
39 |
--------------------------------------------------------------------------------
/packages/backend/src/app.service.ts:
--------------------------------------------------------------------------------
1 | import { Window } from '@doubleshot/nest-electron'
2 | import { Injectable } from '@nestjs/common'
3 | import { screen, dialog, BrowserWindow, shell } from 'electron'
4 | import fs from "fs"
5 |
6 | @Injectable()
7 | export class AppService {
8 | constructor(
9 | @Window() private readonly mainWin: BrowserWindow,
10 | ) {
11 | this.mainWin.webContents.setWindowOpenHandler(({ url }) => {
12 | shell.openExternal(url)
13 | return { action: 'deny' }
14 | })
15 | }
16 |
17 | public getScaleFactor(): number {
18 | const { scaleFactor } = screen.getPrimaryDisplay()
19 | return scaleFactor
20 | }
21 |
22 | public async saveImageToFile(image: string) {
23 | const { canceled, filePath } = await dialog.showSaveDialog(this.mainWin, {
24 | title: 'Save image',
25 | defaultPath: 'paint.png',
26 | filters: [
27 | { name: 'Images', extensions: ['png', 'jpg', 'jpeg'] },
28 | ],
29 | })
30 |
31 | if (canceled) {
32 | return "canceled"
33 | }
34 |
35 | // 从 url 形式的 image base64 转换为 buffer
36 | const buffer = Buffer.from(image.replace(/^data:image\/\w+;base64,/, ""), 'base64')
37 | fs.writeFileSync(filePath, buffer)
38 | return "success"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/packages/backend/src/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core'
2 | import { MicroserviceOptions } from '@nestjs/microservices'
3 | import { app } from 'electron'
4 | import { ElectronIpcTransport } from '@doubleshot/nest-electron'
5 | import { AppModule } from './app.module'
6 |
7 | process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true'
8 |
9 | async function electronAppInit() {
10 | const isDev = !app.isPackaged
11 | app.on('window-all-closed', () => {
12 | if (process.platform !== 'darwin')
13 | app.quit()
14 | })
15 |
16 | if (isDev) {
17 | if (process.platform === 'win32') {
18 | process.on('message', (data) => {
19 | if (data === 'graceful-exit')
20 | app.quit()
21 | })
22 | }
23 | else {
24 | process.on('SIGTERM', () => {
25 | app.quit()
26 | })
27 | }
28 | }
29 |
30 | await app.whenReady()
31 | }
32 |
33 | async function bootstrap() {
34 | try {
35 | await electronAppInit()
36 |
37 | const nestApp = await NestFactory.createMicroservice(
38 | AppModule,
39 | {
40 | strategy: new ElectronIpcTransport(),
41 | },
42 | )
43 |
44 | await nestApp.listen()
45 | }
46 | catch (error) {
47 | app.quit()
48 | }
49 | }
50 |
51 | bootstrap()
52 |
53 |
--------------------------------------------------------------------------------
/packages/backend/src/preload.ts:
--------------------------------------------------------------------------------
1 | import { contextBridge, webFrame, ipcRenderer } from 'electron'
2 |
3 | function getDeviceScaleFactor(): Promise {
4 | return ipcRenderer.invoke("device-scale-factor")
5 | }
6 |
7 | contextBridge.exposeInMainWorld(
8 | 'electron',
9 | {
10 | useZoomFactor: () => {
11 | const DESIGN_HEIGHT = 1080
12 | const DESIGN_DPR = 1
13 | const DESIGN_SCALE_FACTOR = 1
14 | let scaleFactor = 0
15 |
16 | const update = async () => {
17 | const height = window.innerHeight
18 | const dpr = window.devicePixelRatio
19 | if (scaleFactor === 0) scaleFactor = await getDeviceScaleFactor()
20 |
21 | const factor = (height / DESIGN_HEIGHT) * (dpr / DESIGN_DPR) * (DESIGN_SCALE_FACTOR / scaleFactor)
22 | webFrame.setZoomFactor(factor)
23 | }
24 |
25 | return {
26 | update
27 | }
28 | },
29 | saveImageToFile: (image: string): Promise => ipcRenderer.invoke("save-image", image),
30 | },
31 | )
32 |
33 | contextBridge.exposeInMainWorld(
34 | 'isElectron',
35 | true
36 | )
37 |
--------------------------------------------------------------------------------
/packages/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "CommonJS",
5 | "moduleResolution": "node",
6 | "strict": false,
7 | "jsx": "preserve",
8 | "sourceMap": true,
9 | "resolveJsonModule": true,
10 | "esModuleInterop": true,
11 | "noImplicitAny": false,
12 | "experimentalDecorators": true,
13 | "emitDecoratorMetadata": true,
14 | "removeComments": true,
15 | "allowSyntheticDefaultImports": true,
16 | "incremental": true,
17 | "skipLibCheck": true
18 | },
19 | "include": [
20 | "src/**/*.ts",
21 | "src/**/*.d.ts"
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/packages/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/packages/frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | export default {
18 | // other rules...
19 | parserOptions: {
20 | ecmaVersion: 'latest',
21 | sourceType: 'module',
22 | project: ['./tsconfig.json', './tsconfig.node.json'],
23 | tsconfigRootDir: __dirname,
24 | },
25 | }
26 | ```
27 |
28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
31 |
--------------------------------------------------------------------------------
/packages/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Doubleshot App (React + Nest.js)
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/packages/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "react": "^19.0.0",
13 | "react-dom": "^19.0.0"
14 | },
15 | "devDependencies": {
16 | "@types/react": "^19.0.8",
17 | "@types/react-dom": "^19.0.3",
18 | "@vitejs/plugin-react": "^4.3.4",
19 | "typescript": "^5.7.3",
20 | "vite": "^6.1.0"
21 | }
22 | }
--------------------------------------------------------------------------------
/packages/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Doubleshotjs/template-react-nest/24800f94ad10ac22602b9ec836fe8b2d71b0387c/packages/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/packages/frontend/src/App.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.font.im/css?family=Dosis:700,600');
2 |
3 | body {
4 | margin: 0;
5 | }
6 |
7 | #root {
8 | font-family: Dosis, Avenir, Helvetica, Arial, sans-serif;
9 | background-color: rgb(242, 244, 246);
10 | background-size: cover;
11 | background-position: center center;
12 | width: 100vw;
13 | height: 100vh;
14 | --react-color: #087ea4;
15 | --nestjs-color: #ea2845;
16 | }
17 |
18 | .logo-block {
19 | position: absolute;
20 | transform: translate(-50%, -50%);
21 | top: 120px;
22 | left: 50%;
23 | }
24 |
25 | .frame-logos {
26 | display: flex;
27 | align-items: center;
28 | justify-content: center;
29 | }
30 |
31 | .frame-logos .with {
32 | font-size: 30px;
33 | margin: 0 16px;
34 | color: #9ca3af;
35 | }
36 |
37 | .frame-info {
38 | display: flex;
39 | align-items: center;
40 | justify-content: center;
41 | font-size: 30px;
42 | }
43 |
44 | .frame-info img {
45 | width: 30px;
46 | margin-right: 5px;
47 | }
48 |
49 | .react-name {
50 | font-weight: 600;
51 | color: var(--react-color);
52 | }
53 |
54 | .nestjs-name {
55 | font-weight: 600;
56 | color: var(--nestjs-color);
57 | }
--------------------------------------------------------------------------------
/packages/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import './App.css'
2 | import { useEffect } from 'react'
3 | import logo from './assets/logo.png'
4 | import reactLogo from './assets/react.svg'
5 | import nestjsLogo from './assets/nestjs.svg'
6 | import Paint from './components/Paint'
7 |
8 | function App() {
9 | if (window.isElectron) {
10 | const { useZoomFactor } = window.electron
11 | const { update: updateZoomFactor } = useZoomFactor()
12 |
13 | useEffect(() => {
14 | setTimeout(() => {
15 | updateZoomFactor()
16 | }, 200);
17 | }, [])
18 |
19 | window.addEventListener('resize', () => {
20 | updateZoomFactor()
21 | })
22 | }
23 |
24 | return (
25 | <>
26 |
27 |

28 |
29 |
30 |

31 |
React
32 |
33 |
X
34 |
35 |

36 |
Nest.js
37 |
38 |
39 |
40 |
41 | >
42 | )
43 | }
44 |
45 | export default App
46 |
--------------------------------------------------------------------------------
/packages/frontend/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Doubleshotjs/template-react-nest/24800f94ad10ac22602b9ec836fe8b2d71b0387c/packages/frontend/src/assets/logo.png
--------------------------------------------------------------------------------
/packages/frontend/src/assets/nestjs.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/frontend/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/Icons/Clear.tsx:
--------------------------------------------------------------------------------
1 | export default function IconClear() {
2 | return (
3 |
7 | )
8 | }
9 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/Icons/Doc.tsx:
--------------------------------------------------------------------------------
1 |
2 | export default function IconDoc() {
3 | return (
4 |
8 | )
9 | }
--------------------------------------------------------------------------------
/packages/frontend/src/components/Icons/Github.tsx:
--------------------------------------------------------------------------------
1 | export default function IconGithub() {
2 | return (
3 |
7 |
8 | )
9 | }
--------------------------------------------------------------------------------
/packages/frontend/src/components/Icons/Save.tsx:
--------------------------------------------------------------------------------
1 | export default function IconSave() {
2 | return (
3 |
11 | )
12 | }
--------------------------------------------------------------------------------
/packages/frontend/src/components/Icons/index.ts:
--------------------------------------------------------------------------------
1 | import IconClear from './Clear.tsx'
2 | import IconDoc from './Doc.tsx'
3 | import IconGithub from './Github.tsx'
4 | import IconSave from './Save.tsx'
5 |
6 | export {
7 | IconClear,
8 | IconDoc,
9 | IconGithub,
10 | IconSave,
11 | }
--------------------------------------------------------------------------------
/packages/frontend/src/components/Paint.css:
--------------------------------------------------------------------------------
1 | .paint {
2 | display: flex;
3 | align-items: center;
4 | width: 100%;
5 | height: 100%;
6 | }
7 |
8 | .pad {
9 | flex: 1 1 0%;
10 | width: 900px;
11 | height: 600px;
12 | border-radius: 20px;
13 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
14 | background-color: white;
15 | }
16 |
17 | .thickness-bar {
18 | width: calc(50% - 450px);
19 | display: flex;
20 | flex-direction: column;
21 | align-items: flex-end;
22 | }
23 |
24 | .thickness,
25 | .button-in-thickness {
26 | width: 40px;
27 | height: 40px;
28 | position: relative;
29 | margin: 20px 40px;
30 | border: 1px solid rgba(0, 0, 0, 0.05);
31 | border-radius: 50%;
32 | cursor: pointer;
33 | transition: all 0.2s;
34 | }
35 |
36 | .thickness:hover,
37 | .button-in-thickness:hover {
38 | border-color: rgba(0, 0, 0, 0.2);
39 | }
40 |
41 | .button-in-thickness svg {
42 | position: absolute;
43 | top: 50%;
44 | left: 50%;
45 | transform: translate(-50%, -50%);
46 | width: 20px;
47 | height: 20px;
48 | color: rgba(0, 0, 0, 0.2);
49 | transition: all 0.2s;
50 | }
51 |
52 | .button-in-thickness:hover svg {
53 | color: rgba(0, 0, 0, 0.5);
54 | }
55 |
56 | .thickness:after {
57 | position: absolute;
58 | top: 50%;
59 | left: 50%;
60 | content: '';
61 | background-color: rgba(0, 0, 0, 0.2);
62 | border-radius: 50%;
63 | transform: translate(-50%, -50%);
64 | transition: all 0.2s;
65 | width: calc(5px * var(--size-rate));
66 | height: calc(5px * var(--size-rate));
67 | }
68 |
69 | .thickness.active {
70 | border-color: #3498db;
71 | }
72 |
73 | .thickness.active:after {
74 | background-color: #3498db;
75 | }
76 |
77 | .color-bar {
78 | width: calc(50% - 450px);
79 | display: flex;
80 | flex-direction: column;
81 | }
82 |
83 | .color {
84 | width: 30px;
85 | height: 30px;
86 | border-radius: 50%;
87 | display: inline-block;
88 | margin: 20px 40px;
89 | cursor: pointer;
90 | transition: all 0.5s cubic-bezier(0.1, 2, 0.5, 1);
91 | background-color: var(--point-color);
92 | box-shadow: 0 7px 25px var(--shadow-color);
93 | }
94 |
95 | .color:hover {
96 | transform: scale(1.2, 1.2);
97 | }
98 |
99 | .color.active {
100 | transform: scale(1.3, 1.3);
101 | cursor: default;
102 | }
103 |
104 | .button-bar {
105 | position: absolute;
106 | width: 900px;
107 | height: 100px;
108 | transform: translate(-50%, 50%);
109 | bottom: calc((100% - 600px) / 4);
110 | left: 50%;
111 |
112 | display: grid;
113 | grid-template-columns: repeat(3, minmax(0, 1fr));
114 | justify-items: center;
115 | align-items: center;
116 | }
117 |
118 | .button {
119 | width: 260px;
120 | height: 60px;
121 | border-radius: 10px;
122 | background-color: rgba(0, 0, 0, 0.1);
123 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
124 | cursor: pointer;
125 | transition: all 0.2s;
126 | color: #fff;
127 | font-size: 30px;
128 | display: flex;
129 | align-items: center;
130 | justify-content: center;
131 | text-decoration: none;
132 | }
133 |
134 | .button svg {
135 | margin-right: 10px;
136 | }
137 |
138 | .button:hover {
139 | transform: scale(1.1, 1.1);
140 | }
141 |
142 | .button.save {
143 | background-color: #3498db;
144 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
145 | }
146 |
147 | .button.doc {
148 | background-color: #24b574;
149 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
150 | }
151 |
152 | .button.github {
153 | background-color: #24292f;
154 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
155 | }
--------------------------------------------------------------------------------
/packages/frontend/src/components/Paint.tsx:
--------------------------------------------------------------------------------
1 | import './Paint.css'
2 | import { useEffect, useRef, useState } from "react"
3 | import { IconClear, IconSave, IconDoc, IconGithub } from './Icons'
4 |
5 | const PAD_WIDTH = 1800
6 | const PAD_HEIGHT = 1200
7 | const THICKNESSES = [4, 12, 24, 48, 128]
8 | const COLORS = ['#9b59b6', '#3498db', '#2ecc71', '#1abc9c', '#f1c40f', '#e67e22', '#E73C61']
9 |
10 | function Paint() {
11 | const [selectedThickness, setSelectedThickness] = useState(24)
12 | const [selectedColor, setSelectedColor] = useState('#1abc9c')
13 |
14 | const drawPad = useRef(null)
15 |
16 | useEffect(() => {
17 | const ctx = drawPad.current?.getContext('2d')
18 | if (!ctx) return
19 |
20 | const c = drawPad.current!
21 |
22 | ctx.fillStyle = '#fff'
23 | ctx.fillRect(0, 0, c.width, c.height)
24 |
25 | c.width = PAD_WIDTH
26 | c.height = PAD_HEIGHT
27 | let isPressed = false
28 |
29 | c.addEventListener('mousemove', (e) => {
30 | let x = e.offsetX * 2
31 | let y = e.offsetY * 2
32 |
33 | if (isPressed && ctx) {
34 | ctx.lineTo(x, y)
35 | ctx.stroke()
36 | }
37 | })
38 |
39 | c.addEventListener('mousedown', (e) => {
40 | ctx.beginPath()
41 | ctx.moveTo(e.offsetX * 2, e.offsetY * 2)
42 | isPressed = true
43 | })
44 |
45 | c.addEventListener('mouseup', () => {
46 | isPressed = false
47 | ctx.closePath()
48 | })
49 |
50 | c.addEventListener('mouseleave', () => {
51 | isPressed = false
52 | ctx.closePath()
53 | })
54 |
55 | // Hi!
56 | ctx.lineWidth = 48
57 | ctx.strokeStyle = '#24b574'
58 | ctx.lineCap = 'round'
59 | ctx.lineJoin = 'round'
60 | ctx.beginPath()
61 | ctx.moveTo(600, 350)
62 | ctx.lineTo(640, 800)
63 |
64 | ctx.moveTo(600, 600)
65 | ctx.lineTo(800, 580)
66 |
67 | ctx.moveTo(800, 350)
68 | ctx.lineTo(840, 800)
69 |
70 | ctx.moveTo(1010, 500)
71 | ctx.lineTo(1000, 800)
72 |
73 | ctx.moveTo(1010, 310)
74 | ctx.lineTo(1010, 340)
75 |
76 | ctx.moveTo(1210, 310)
77 | ctx.lineTo(1200, 640)
78 |
79 | ctx.moveTo(1225, 720)
80 | ctx.lineTo(1180, 820)
81 |
82 | ctx.moveTo(1170, 730)
83 | ctx.lineTo(1240, 820)
84 |
85 | ctx.closePath()
86 |
87 | ctx.stroke()
88 |
89 | // init thickness & color
90 | ctx.lineWidth = selectedThickness
91 | ctx.strokeStyle = selectedColor
92 | ctx.lineCap = 'round'
93 | ctx.lineJoin = 'round'
94 | }, [drawPad])
95 |
96 | useEffect(() => {
97 | const ctx = drawPad.current?.getContext('2d')
98 | if (!ctx) return
99 |
100 | ctx.strokeStyle = selectedColor
101 | ctx.lineWidth = selectedThickness
102 | }, [selectedThickness, selectedColor])
103 |
104 | const clearCanvas = () => {
105 | const ctx = drawPad.current?.getContext('2d')
106 | if (!ctx) return
107 |
108 | ctx.fillStyle = '#fff'
109 | ctx.fillRect(0, 0, PAD_WIDTH, PAD_HEIGHT)
110 | }
111 |
112 | const saveImage = () => {
113 | if (!drawPad.current) return
114 |
115 | const img = drawPad.current.toDataURL('image/png')
116 | if (window.isElectron) {
117 | window.electron.saveImageToFile(img)
118 | } else {
119 | const a = document.createElement('a')
120 | a.href = img
121 | a.download = 'paint.png'
122 | a.click()
123 | }
124 | }
125 |
126 | return (
127 |
128 |