├── .eslintignore
├── .eslintrc.cjs
├── .github
└── workflows
│ └── deploy-gh-pages.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode
└── extensions.json
├── LICENSE
├── README.md
├── electron-preload.cjs
├── electron.cjs
├── forge.config.cjs
├── index.html
├── package-lock.json
├── package.json
├── preview_dark.png
├── preview_light.png
├── public
├── favicon.icns
├── favicon.ico
├── favicon.png
├── favicon.svg
├── favicon@1x.png
├── favicon@2x.png
└── service-worker.js
├── src
├── App.svelte
├── global.css
├── global.d.ts
├── lib
│ ├── Close.svelte
│ ├── Editor.svelte
│ ├── Fullscreen.svelte
│ ├── Hide.svelte
│ ├── IconButton.svelte
│ ├── Statistics.svelte
│ ├── Stopwatch.svelte
│ ├── Theme.svelte
│ ├── Time.svelte
│ ├── Titlebar.svelte
│ ├── stores
│ │ ├── app.ts
│ │ └── editor.ts
│ └── utils
│ │ ├── calculatePosition
│ │ └── index.ts
│ │ ├── classnames
│ │ └── index.ts
│ │ ├── countCharacters
│ │ └── index.ts
│ │ ├── countParagraphs
│ │ └── index.ts
│ │ ├── countSentences
│ │ └── index.ts
│ │ ├── countWords
│ │ └── index.ts
│ │ ├── fullscreen
│ │ └── index.ts
│ │ └── persistenceStorage
│ │ └── index.ts
├── main.ts
├── registerServiceWorker.ts
└── vite-env.d.ts
├── svelte.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.eslintignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /dist
5 | /out
6 | /package
7 | .env
8 | .env.*
9 | !.env.example
10 |
11 | # Ignore files for PNPM, NPM and YARN
12 | pnpm-lock.yaml
13 | package-lock.json
14 | yarn.lock
15 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: "@typescript-eslint/parser",
4 | extends: [
5 | "eslint:recommended",
6 | "plugin:@typescript-eslint/recommended",
7 | "prettier",
8 | ],
9 | plugins: ["svelte3", "@typescript-eslint"],
10 | ignorePatterns: ["*.cjs"],
11 | overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }],
12 | settings: {
13 | "svelte3/typescript": () => require("typescript"),
14 | },
15 | parserOptions: {
16 | sourceType: "module",
17 | ecmaVersion: 2020,
18 | },
19 | env: {
20 | browser: true,
21 | es2017: true,
22 | node: true,
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-gh-pages.yml:
--------------------------------------------------------------------------------
1 | # Simple workflow for deploying static content to GitHub Pages
2 | name: Deploy static content to Pages
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ['main']
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow one concurrent deployment
19 | concurrency:
20 | group: 'pages'
21 | cancel-in-progress: true
22 |
23 | jobs:
24 | # Single deploy job since we're just deploying
25 | deploy:
26 | environment:
27 | name: github-pages
28 | url: ${{ steps.deployment.outputs.page_url }}
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v3
33 | - name: Set up Node
34 | uses: actions/setup-node@v3
35 | with:
36 | node-version: 18
37 | cache: 'npm'
38 | - name: Install dependencies
39 | run: npm install
40 | - name: Build
41 | run: npm run frontend:build:web
42 | - name: Setup Pages
43 | uses: actions/configure-pages@v3
44 | - name: Upload artifact
45 | uses: actions/upload-pages-artifact@v1
46 | with:
47 | # Upload dist repository
48 | path: './dist'
49 | - name: Deploy to GitHub Pages
50 | id: deployment
51 | uses: actions/deploy-pages@v1
52 |
--------------------------------------------------------------------------------
/.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 |
26 | out/
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /dist
5 | /out
6 | /.svelte-kit
7 | /package
8 | .env
9 | .env.*
10 | !.env.example
11 |
12 | # Ignore files for PNPM, NPM and YARN
13 | pnpm-lock.yaml
14 | package-lock.json
15 | yarn.lock
16 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-svelte"],
3 | "pluginSearchDirs": ["."],
4 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
5 | }
6 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["svelte.svelte-vscode"]
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Stepan Kurennykh
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 | # Writer
2 |
3 | Writer is a distraction-free text editor to improve your writing skills. It includes statistics, stopwatch, dark/light theme and many other little tricks to make your writing experience more pleasant.
4 |
5 | I started this project to help myself to improve my writing skills, especially IELTS writing part. While I was experimenting with many tools and approaches, I discovered that the best way to level-up is to eliminate any grammar and spellcheckers and focus solely on the process of writing. Unfortunately, I couldn't find the right app for me because all of them either they had bad design/interface, IMHO, or they were bloated with unnecessary futures or spellcheckers.
6 |
7 | The app has statistics that shows how many characters, words, sentences, or paragraphs you have written. It's helpful when you're trying to meet some goals, for example, at least 250 words for IELTS essay part. Moreover, the app has a stopwatch that you can use to track your writing speed or create the pressure to emulate exam conditions.
8 |
9 | The app available [online](https://stoope.github.io/writer/). For the better experience, I recommend building your own desktop version
10 |
11 | 
12 | 
13 |
14 | ## Prerequisites
15 |
16 | You must have `nodejs` >= 18 and `npm` >= 9 to run and build this app. Work with early versions is not guaranteed.
17 |
18 | ## Install
19 |
20 | ```bash
21 | git clone https://github.com/stoope/writer.git
22 | npm install
23 | ```
24 |
25 | ## Run
26 |
27 | ```bash
28 | npm run start
29 | ```
30 |
31 | ## Build
32 |
33 | You might have to change the `electron-forge` configuration file `forge.config.cjs` to build the app for your specific platform.
34 |
35 | ```bash
36 | npm run build
37 | ```
38 |
39 | After build, you could find the app under `out` folder.
40 |
41 | ## Your help is appreciated!
42 |
43 | If you want to discuss your ideas or create a Pull Requests you're welcome!
44 |
--------------------------------------------------------------------------------
/electron-preload.cjs:
--------------------------------------------------------------------------------
1 | const { ipcRenderer, contextBridge } = require("electron");
2 |
3 | contextBridge.exposeInMainWorld("ipcRenderer", {
4 | send: ipcRenderer.send.bind(ipcRenderer),
5 | on: ipcRenderer.on.bind(ipcRenderer),
6 | invoke: ipcRenderer.invoke.bind(ipcRenderer),
7 | });
8 |
--------------------------------------------------------------------------------
/electron.cjs:
--------------------------------------------------------------------------------
1 | const { app, BrowserWindow, ipcMain, Menu, nativeTheme } = require("electron");
2 | const path = require("path");
3 | const Store = require("electron-store");
4 |
5 | const store = new Store();
6 |
7 | const WINDOW_BONDS_KEY = "editor:winBounds";
8 |
9 | const isMac = process.platform === "darwin";
10 |
11 | const createWindow = async () => {
12 | const windowBound = store.get(WINDOW_BONDS_KEY) ?? {};
13 |
14 | const mainWindow = new BrowserWindow({
15 | width: 600,
16 | height: 800,
17 | minWidth: 360,
18 | minHeight: 360,
19 | icon: path.join(
20 | __dirname,
21 | "public",
22 | { darwin: "favicon.icns", linux: "favicon.png", win32: "favicon.ico" }[
23 | process.platform
24 | ] || "favicon.ico"
25 | ),
26 | frame: !isMac,
27 | skipTaskbar: isMac,
28 | autoHideMenuBar: isMac,
29 | webPreferences: {
30 | backgroundThrottling: false,
31 | preload: path.resolve(__dirname, "electron-preload.cjs"),
32 | contextIsolation: true,
33 | },
34 | ...windowBound,
35 | });
36 |
37 | ipcMain.handle("toggleFullscreen", function () {
38 | mainWindow.setFullScreen(!mainWindow.isFullScreen());
39 | });
40 |
41 | ipcMain.handle("close", function () {
42 | app.quit();
43 | });
44 |
45 | ipcMain.handle("minimize", function () {
46 | mainWindow.minimize();
47 | });
48 |
49 | ipcMain.handle("setSetting", function (_event, { key, value }) {
50 | if (key === "app:theme") {
51 | nativeTheme.themeSource = value;
52 | }
53 | return store.set(key, value);
54 | });
55 |
56 | ipcMain.handle("getSetting", function (_event, { key }) {
57 | return store.get(key);
58 | });
59 |
60 | mainWindow.on("close", function () {
61 | store.set(WINDOW_BONDS_KEY, mainWindow.getBounds());
62 | });
63 |
64 | mainWindow.on("closed", function () {
65 | app.quit();
66 | });
67 |
68 | if (process.env.NODE_ENV !== "development") {
69 | await mainWindow.loadFile(path.join(__dirname, "dist", "index.html"));
70 | } else {
71 | await mainWindow.loadURL("http://localhost:3000/");
72 | }
73 |
74 | if (process.env.NODE_ENV === "development") {
75 | mainWindow.webContents.openDevTools();
76 | }
77 | };
78 |
79 | app.whenReady().then(() => {
80 | createWindow();
81 |
82 | app.on("activate", () => {
83 | if (BrowserWindow.getAllWindows().length === 0) createWindow();
84 | });
85 | });
86 |
87 | app.on("window-all-closed", () => {
88 | if (!isMac) app.quit();
89 | });
90 |
--------------------------------------------------------------------------------
/forge.config.cjs:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const fs = require("fs");
3 |
4 | module.exports = {
5 | packagerConfig: {
6 | icon: "./public/favicon",
7 | },
8 | rebuildConfig: {},
9 | makers: [
10 | {
11 | name: "@electron-forge/maker-squirrel",
12 | config: {},
13 | },
14 | {
15 | name: "@electron-forge/maker-zip",
16 | platforms: ["darwin"],
17 | },
18 | {
19 | name: "@electron-forge/maker-deb",
20 | config: {},
21 | },
22 | ],
23 | publishers: [
24 | {
25 | name: "@electron-forge/publisher-github",
26 | config: {
27 | repository: {
28 | owner: "stoope",
29 | name: "writer",
30 | },
31 | prerelease: false,
32 | draft: false,
33 | },
34 | },
35 | ],
36 | };
37 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 | Writer
13 |
14 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "writer",
3 | "description": "Writer without distractions",
4 | "private": true,
5 | "version": "1.0.0",
6 | "type": "module",
7 | "main": "electron.cjs",
8 | "scripts": {
9 | "start": "NODE_ENV=development concurrently \"npm run frontend:start\" \"npm run electron:start\"",
10 | "frontend:start": "vite --port 3000 --host localhost",
11 | "frontend:start:web": "VITE_WEB=true vite --port 3000 --host localhost",
12 | "frontend:build": "vite build",
13 | "frontend:build:web": "VITE_WEB=true vite build",
14 | "frontend:preview": "vite preview",
15 | "frontend:check": "svelte-check --tsconfig ./tsconfig.json",
16 | "electron:start": "electron .",
17 | "electron:build": "electron-forge make",
18 | "build": "npm run frontend:build && npm run electron:build",
19 | "package": "electron-forge package",
20 | "publish": "electron-forge publish",
21 | "lint": "prettier --plugin-search-dir . --check . && eslint .",
22 | "format": "prettier --plugin-search-dir . --write ."
23 | },
24 | "devDependencies": {
25 | "@electron-forge/cli": "^6.0.5",
26 | "@electron-forge/maker-deb": "^6.0.5",
27 | "@electron-forge/maker-rpm": "^6.0.5",
28 | "@electron-forge/maker-squirrel": "^6.0.5",
29 | "@electron-forge/maker-zip": "^6.0.5",
30 | "@electron-forge/publisher-github": "^6.0.5",
31 | "@sveltejs/vite-plugin-svelte": "^2.0.3",
32 | "@tsconfig/svelte": "^3.0.0",
33 | "@types/debounce": "^1.2.1",
34 | "@typescript-eslint/eslint-plugin": "^5.54.0",
35 | "@typescript-eslint/parser": "^5.54.0",
36 | "concurrently": "^7.6.0",
37 | "electron": "^23.1.1",
38 | "eslint": "^8.35.0",
39 | "eslint-config-prettier": "^8.6.0",
40 | "eslint-plugin-svelte3": "^4.0.0",
41 | "prettier": "^2.8.4",
42 | "prettier-plugin-svelte": "^2.9.0",
43 | "svelte": "^3.55.1",
44 | "svelte-check": "^3.0.4",
45 | "tslib": "^2.5.0",
46 | "typescript": "^4.9.5",
47 | "vite": "^4.1.4"
48 | },
49 | "dependencies": {
50 | "@fontsource/courier-prime": "^4.5.9",
51 | "@fontsource/fira-code": "^4.5.13",
52 | "debounce": "^1.2.1",
53 | "electron-squirrel-startup": "^1.0.0",
54 | "electron-store": "^8.1.0"
55 | },
56 | "volta": {
57 | "node": "18.14.1"
58 | },
59 | "engines": {
60 | "node": ">=18",
61 | "npm": ">=9"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/preview_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stoope/writer/8c7a01a8b1251e6df3d60297026ec6ffbe2a3ecf/preview_dark.png
--------------------------------------------------------------------------------
/preview_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stoope/writer/8c7a01a8b1251e6df3d60297026ec6ffbe2a3ecf/preview_light.png
--------------------------------------------------------------------------------
/public/favicon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stoope/writer/8c7a01a8b1251e6df3d60297026ec6ffbe2a3ecf/public/favicon.icns
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stoope/writer/8c7a01a8b1251e6df3d60297026ec6ffbe2a3ecf/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stoope/writer/8c7a01a8b1251e6df3d60297026ec6ffbe2a3ecf/public/favicon.png
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/favicon@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stoope/writer/8c7a01a8b1251e6df3d60297026ec6ffbe2a3ecf/public/favicon@1x.png
--------------------------------------------------------------------------------
/public/favicon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stoope/writer/8c7a01a8b1251e6df3d60297026ec6ffbe2a3ecf/public/favicon@2x.png
--------------------------------------------------------------------------------
/public/service-worker.js:
--------------------------------------------------------------------------------
1 | const cacheName = "v1";
2 |
3 | self.addEventListener("fetch", async function (event) {
4 | if (event.request.destination === "font") {
5 | event.respondWith(
6 | caches.open(cacheName).then((cache) => {
7 | return cache.match(event.request).then((cachedResponse) => {
8 | const fetchedResponse = fetch(event.request).then(
9 | (networkResponse) => {
10 | cache.put(event.request, networkResponse.clone());
11 |
12 | return networkResponse;
13 | }
14 | );
15 |
16 | return cachedResponse || fetchedResponse;
17 | });
18 | })
19 | );
20 | } else {
21 | return;
22 | }
23 | });
24 |
--------------------------------------------------------------------------------
/src/App.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
78 |
--------------------------------------------------------------------------------
/src/global.css:
--------------------------------------------------------------------------------
1 | body {
2 | --background: rgb(var(--background-rgb));
3 | --foreground: rgb(var(--foreground-rgb));
4 | --accent: var(--cyan);
5 | --black: rgb(var(--black-rgb));
6 | --white: rgb(var(--white-rgb));
7 | --comment: rgb(var(--comment-rgb));
8 | --comment-rgb: 121, 112, 169;
9 | --selection-rgb: 69, 65, 88;
10 | --cyan-rgb: 128, 255, 234;
11 | --green-rgb: 138, 255, 128;
12 | --orange-rgb: 255, 202, 128;
13 | --pink-rgb: 255, 128, 191;
14 | --purple-rgb: 149, 128, 255;
15 | --red-rgb: 255, 149, 128;
16 | --yellow-rgb: 255, 255, 128;
17 | --black-rgb: 34, 33, 44;
18 | --white-rgb: 248, 248, 242;
19 | --selection-rgb-light: 176, 172, 194;
20 | --cyan-rgb-light: 0, 229, 191;
21 | --green-rgb-light: 18, 229, 0;
22 | --orange-rgb-light: 229, 133, 0;
23 | --pink-rgb-light: 229, 0, 113;
24 | --purple-rgb-light: 37, 0, 229;
25 | --red-rgb-light: 229, 37, 0;
26 | --yellow-rgb-light: 229, 229, 0;
27 | -webkit-app-region: drag;
28 | }
29 |
30 | body {
31 | background-color: var(--background);
32 | color: var(--foreground);
33 | font-size: 16px;
34 | font-family: "Fira Code", monospace;
35 | }
36 |
37 | @media (prefers-color-scheme: dark) {
38 | body:not(.light-theme) {
39 | --background-rgb: var(--black-rgb);
40 | --foreground-rgb: var(--white-rgb);
41 | --selection: rgb(var(--selection-rgb));
42 | --cyan: rgb(var(--cyan-rgb));
43 | --green: rgb(var(--green-rgb));
44 | --orange: rgb(var(--orange-rgb));
45 | --pink: rgb(var(--pink-rgb));
46 | --purple: rgb(var(--purple-rgb));
47 | --red: rgb(var(--red-rgb));
48 | --yellow: rgb(var(--yellow-rgb));
49 | }
50 | }
51 | body.dark-theme:not(.light-theme) {
52 | --background-rgb: var(--black-rgb);
53 | --foreground-rgb: var(--white-rgb);
54 | --selection: rgb(var(--selection-rgb));
55 | --cyan: rgb(var(--cyan-rgb));
56 | --green: rgb(var(--green-rgb));
57 | --orange: rgb(var(--orange-rgb));
58 | --pink: rgb(var(--pink-rgb));
59 | --purple: rgb(var(--purple-rgb));
60 | --red: rgb(var(--red-rgb));
61 | --yellow: rgb(var(--yellow-rgb));
62 | }
63 |
64 | @media (prefers-color-scheme: light) {
65 | body:not(.dark-theme) {
66 | --background-rgb: var(--white-rgb);
67 | --foreground-rgb: var(--black-rgb);
68 | --accent: var(--orange);
69 | --selection: rgb(var(--selection-rgb-light));
70 | --cyan: rgb(var(--cyan-rgb-light));
71 | --green: rgb(var(--green-rgb-light));
72 | --orange: rgb(var(--orange-rgb-light));
73 | --pink: rgb(var(--pink-rgb-light));
74 | --purple: rgb(var(--purple-rgb-light));
75 | --red: rgb(var(--red-rgb-light));
76 | --yellow: rgb(var(--yellow-rgb-light));
77 | }
78 | }
79 | body.light-theme:not(.dark-theme) {
80 | --background-rgb: var(--white-rgb);
81 | --foreground-rgb: var(--black-rgb);
82 | --accent: var(--orange);
83 | --selection: rgb(var(--selection-rgb-light));
84 | --cyan: rgb(var(--cyan-rgb-light));
85 | --green: rgb(var(--green-rgb-light));
86 | --orange: rgb(var(--orange-rgb-light));
87 | --pink: rgb(var(--pink-rgb-light));
88 | --purple: rgb(var(--purple-rgb-light));
89 | --red: rgb(var(--red-rgb-light));
90 | --yellow: rgb(var(--yellow-rgb-light));
91 | }
92 |
93 | * {
94 | box-sizing: border-box;
95 | padding: 0;
96 | margin: 0;
97 | }
98 |
99 | ::selection {
100 | color: var(--foreground);
101 | background-color: var(--selection);
102 | opacity: 1;
103 | }
104 |
105 | html,
106 | body,
107 | #app {
108 | height: 100%;
109 | }
110 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | declare interface Window {
3 | ipcRenderer: {
4 | invoke(channel: string, ...args: any[]): Promise;
5 | send(channel: string, ...args: any[]): void;
6 | on(channel: string, listener: (event: any, ...args: any[]) => void): this;
7 | };
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/Close.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
26 |
27 |
36 |
--------------------------------------------------------------------------------
/src/lib/Editor.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
37 |
38 |
76 |
--------------------------------------------------------------------------------
/src/lib/Fullscreen.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
33 | {#if $fullscreen}
34 |
45 | {:else}
46 | {/if}
58 |
59 |
60 |
69 |
--------------------------------------------------------------------------------
/src/lib/Hide.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
32 |
33 |
46 |
--------------------------------------------------------------------------------
/src/lib/IconButton.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
14 |
15 |
33 |
--------------------------------------------------------------------------------
/src/lib/Statistics.svelte:
--------------------------------------------------------------------------------
1 |
37 |
38 |
39 | {#if $selectionStart !== $selectionEnd}
40 |
41 | [{$selectionStart},{$selectionEnd}]
42 |
43 | {/if}
44 | {characters}C
45 | {words}W
46 | {sentences}S
47 | {paragraphs}P
48 |
49 | {position.toFixed(2).padStart(6, " ")}%
50 |
51 |
52 |
53 |
66 |
--------------------------------------------------------------------------------
/src/lib/Stopwatch.svelte:
--------------------------------------------------------------------------------
1 |
61 |
62 |
63 |
64 | {hours}:{minutes}:{seconds}:{milliseconds}
65 |
66 |
67 | {#if running}
68 |
69 |
82 | {:else}
83 |
84 |
97 | {/if}
98 |
99 |
100 |
101 |
114 |
115 |
116 |
117 |
132 |
--------------------------------------------------------------------------------
/src/lib/Theme.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
29 | {#if $theme === "dark"}
30 |
40 | {:else if $theme === "light"}
41 |
127 | {:else if $theme === "system"}
128 |
139 | {/if}
141 |
142 |
143 |
150 |
--------------------------------------------------------------------------------
/src/lib/Time.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 | {formatter.format(time)}
25 |
26 |
27 |
32 |
--------------------------------------------------------------------------------
/src/lib/Titlebar.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
13 | {#if !import.meta.env.VITE_WEB}
14 |
15 |
16 | {/if}
17 |
18 |
19 |
20 |
21 |
22 |
62 |
--------------------------------------------------------------------------------
/src/lib/stores/app.ts:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 | import { setItem, getItem } from "../utils/persistenceStorage";
3 |
4 | const THEME_KEY = "app:theme";
5 |
6 | type Theme = "light" | "dark" | "system";
7 |
8 | const setTheme = function (value: Theme) {
9 | setItem(THEME_KEY, value);
10 | switchThemeInApp(value);
11 | };
12 |
13 | export const theme = writable("system");
14 | export const fullscreen = writable(false);
15 |
16 | export function switchThemeInApp(value: Theme) {
17 | if (import.meta.env.VITE_WEB) {
18 | document.body.classList.remove("light-theme");
19 | document.body.classList.remove("dark-theme");
20 | if (value === "light") {
21 | document.body.classList.add("light-theme");
22 | } else if (value === "dark") {
23 | document.body.classList.add("dark-theme");
24 | }
25 | }
26 | }
27 |
28 | getItem(THEME_KEY).then(function (_theme) {
29 | _theme = _theme ?? "system";
30 |
31 | theme.set(_theme ?? "system");
32 |
33 | switchThemeInApp(_theme);
34 |
35 | requestAnimationFrame(function () {
36 | document.head.insertAdjacentHTML(
37 | "beforeend",
38 | ``
46 | );
47 | });
48 |
49 | theme.subscribe(setTheme);
50 | });
51 |
--------------------------------------------------------------------------------
/src/lib/stores/editor.ts:
--------------------------------------------------------------------------------
1 | import { debounce } from "debounce";
2 | import { writable, get } from "svelte/store";
3 | import { getItem, setItem } from "../utils/persistenceStorage";
4 |
5 | const VALUE_KEY = "editor:value";
6 | const SELECTION_START_KEY = "editor:selectionStart";
7 | const SELECTION_END_KEY = "editor:selectionEnd";
8 | const SCROLL_KEY = "editor:scrollTop";
9 |
10 | const setValue = debounce(function (value: string) {
11 | setItem(VALUE_KEY, value);
12 | }, 100);
13 |
14 | const setSelectionStart = debounce(function (value: number) {
15 | setItem(SELECTION_START_KEY, value);
16 | }, 100);
17 |
18 | const setSelectionEnd = debounce(function (value: number) {
19 | setItem(SELECTION_END_KEY, value);
20 | }, 100);
21 |
22 | const setScrollTop = debounce(function (value: number) {
23 | setItem(SCROLL_KEY, value);
24 | }, 100);
25 |
26 | export const ref = writable();
27 | export const value = writable("");
28 | export const scrollTop = writable(0);
29 | export const selectionStart = writable(0);
30 | export const selectionEnd = writable(0);
31 |
32 | value.subscribe(setValue);
33 | scrollTop.subscribe(setScrollTop);
34 | selectionStart.subscribe(setSelectionStart);
35 | selectionEnd.subscribe(setSelectionEnd);
36 |
37 | export function handleSelectionChange() {
38 | selectionStart.set(get(ref).selectionStart ?? 0);
39 | selectionEnd.set(get(ref).selectionEnd ?? 0);
40 | }
41 |
42 | export function focus() {
43 | get(ref).focus();
44 | get(ref).scrollTop = get(scrollTop);
45 | }
46 |
47 | Promise.all([
48 | getItem(VALUE_KEY),
49 | getItem(SELECTION_START_KEY),
50 | getItem(SELECTION_END_KEY),
51 | getItem(SCROLL_KEY),
52 | ]).then(function ([_value, _selectionStart, _selectionEnd, _scrollTop]) {
53 | value.set(_value ?? "");
54 | if (_value !== null) {
55 | get(ref).value = _value;
56 | }
57 |
58 | if (_selectionStart !== null && _selectionEnd !== null) {
59 | get(ref).setSelectionRange(_selectionStart, _selectionEnd);
60 | }
61 |
62 | if (_scrollTop !== null) {
63 | get(ref).scrollTop = _scrollTop;
64 | }
65 | });
66 |
--------------------------------------------------------------------------------
/src/lib/utils/calculatePosition/index.ts:
--------------------------------------------------------------------------------
1 | import { countCharacters } from "../countCharacters";
2 |
3 | function clamp(v: number, min: number, max: number) {
4 | return v < min ? min : v > max ? max : v;
5 | }
6 |
7 | function calculatePosition(text: string, selectionEnd: number) {
8 | if (selectionEnd <= 0) {
9 | return 0;
10 | }
11 |
12 | const before = countCharacters(text, 0, selectionEnd);
13 | const after = countCharacters(text, selectionEnd, text.length);
14 |
15 | if (before + after === 0) {
16 | return 0;
17 | }
18 |
19 | return clamp((before / (before + after)) * 100, 0, 100);
20 | }
21 |
22 | export { calculatePosition };
23 |
--------------------------------------------------------------------------------
/src/lib/utils/classnames/index.ts:
--------------------------------------------------------------------------------
1 | function classnames(
2 | ...classes: Array
3 | ) {
4 | return classes.filter(Boolean).join(" ");
5 | }
6 |
7 | export { classnames };
8 |
--------------------------------------------------------------------------------
/src/lib/utils/countCharacters/index.ts:
--------------------------------------------------------------------------------
1 | const EOL = "\n";
2 |
3 | function countCharacters(
4 | text: string,
5 | selectionStart: number,
6 | selectionEnd: number
7 | ) {
8 | let count = 0;
9 |
10 | for (let i = selectionStart; i < selectionEnd; i += 1) {
11 | if (/\s/g.test(text[i]) || text[i] === EOL) {
12 | continue;
13 | }
14 | count += 1;
15 | }
16 |
17 | return count;
18 | }
19 |
20 | export { countCharacters };
21 |
--------------------------------------------------------------------------------
/src/lib/utils/countParagraphs/index.ts:
--------------------------------------------------------------------------------
1 | const EOL = "\n";
2 |
3 | function countParagraphs(
4 | text: string,
5 | selectionStart: number,
6 | selectionEnd: number
7 | ) {
8 | let count = 0;
9 | let currentLength = 0;
10 |
11 | for (let i = selectionStart; i < selectionEnd; i += 1) {
12 | if (text[i] === EOL) {
13 | if (currentLength > 0) {
14 | count += 1;
15 | }
16 | currentLength = 0;
17 | } else {
18 | if (/\s/g.test(text[i])) {
19 | continue;
20 | }
21 | currentLength += 1;
22 | }
23 | }
24 |
25 | if (currentLength > 0) {
26 | count += 1;
27 | }
28 |
29 | return count;
30 | }
31 |
32 | export { countParagraphs };
33 |
--------------------------------------------------------------------------------
/src/lib/utils/countSentences/index.ts:
--------------------------------------------------------------------------------
1 | const EOL = "\n";
2 |
3 | function countSentences(
4 | text: string,
5 | selectionStart: number,
6 | selectionEnd: number
7 | ) {
8 | let count = 0;
9 | let currentLength = 0;
10 |
11 | for (let i = selectionStart; i < selectionEnd; i += 1) {
12 | if (/[.!?]/.test(text[i])) {
13 | if (currentLength > 0) {
14 | count += 1;
15 | }
16 | currentLength = 0;
17 | } else {
18 | if (/\s/g.test(text[i]) || text[i] === EOL) {
19 | continue;
20 | }
21 | currentLength += 1;
22 | }
23 | }
24 |
25 | if (currentLength > 0) {
26 | count += 1;
27 | }
28 |
29 | return count;
30 | }
31 |
32 | export { countSentences };
33 |
--------------------------------------------------------------------------------
/src/lib/utils/countWords/index.ts:
--------------------------------------------------------------------------------
1 | const EOL = "\n";
2 |
3 | function countWords(
4 | text: string,
5 | selectionStart: number,
6 | selectionEnd: number
7 | ) {
8 | let count = 0;
9 | let currentLength = 0;
10 |
11 | for (let i = selectionStart; i < selectionEnd; i += 1) {
12 | if (/\s/g.test(text[i]) || text[i] === EOL) {
13 | if (currentLength > 0) {
14 | count += 1;
15 | }
16 | currentLength = 0;
17 | } else {
18 | currentLength += 1;
19 | }
20 | }
21 |
22 | if (currentLength > 0) {
23 | count += 1;
24 | }
25 |
26 | return count;
27 | }
28 |
29 | export { countWords };
30 |
--------------------------------------------------------------------------------
/src/lib/utils/fullscreen/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | function openFullscreen() {
4 | const documentElement = document.documentElement;
5 |
6 | if (documentElement.requestFullscreen) {
7 | documentElement.requestFullscreen();
8 | } else if ((documentElement as any).webkitRequestFullscreen) {
9 | /* Safari */
10 | (documentElement as any).webkitRequestFullscreen();
11 | } else if ((documentElement as any).msRequestFullscreen) {
12 | /* IE11 */
13 | (documentElement as any).msRequestFullscreen();
14 | }
15 | }
16 |
17 | /* Close fullscreen */
18 | function closeFullscreen() {
19 | if (document.exitFullscreen) {
20 | document.exitFullscreen();
21 | } else if ((document as any).webkitExitFullscreen) {
22 | /* Safari */
23 | (document as any).webkitExitFullscreen();
24 | } else if ((document as any).msExitFullscreen) {
25 | /* IE11 */
26 | (document as any).msExitFullscreen();
27 | }
28 | }
29 |
30 | export { openFullscreen, closeFullscreen };
31 |
--------------------------------------------------------------------------------
/src/lib/utils/persistenceStorage/index.ts:
--------------------------------------------------------------------------------
1 | type JSONValue =
2 | | string
3 | | number
4 | | boolean
5 | | { [x: string]: JSONValue }
6 | | Array;
7 |
8 | async function setItem(key: string, value: JSONValue) {
9 | if (import.meta.env.VITE_WEB) {
10 | window.localStorage.setItem(key, JSON.stringify(value));
11 | } else {
12 | return window.ipcRenderer.invoke("setSetting", { key, value });
13 | }
14 | }
15 |
16 | async function getItem(key: string): Promise {
17 | if (import.meta.env.VITE_WEB) {
18 | try {
19 | const item = window.localStorage.getItem(key);
20 | if (item !== null) {
21 | return JSON.parse(item);
22 | }
23 | } catch (error) {
24 | /* empty */
25 | }
26 | } else {
27 | const item = await window.ipcRenderer.invoke("getSetting", { key });
28 | return item;
29 | }
30 | return null;
31 | }
32 |
33 | export { setItem, getItem };
34 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import "./global.css";
2 | import App from "./App.svelte";
3 | import { registerServiceWorker } from "./registerServiceWorker";
4 |
5 | const app = new App({
6 | target: document.getElementById("app"),
7 | });
8 |
9 | export default app;
10 |
11 | if (import.meta.env.VITE_WEB) {
12 | registerServiceWorker();
13 | }
14 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.ts:
--------------------------------------------------------------------------------
1 | export async function registerServiceWorker() {
2 | if ("serviceWorker" in navigator) {
3 | try {
4 | await navigator.serviceWorker.register("./service-worker.js");
5 | } catch (error) {
6 | console.error(`Service worker registration failed with ${error}`);
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
2 |
3 | export default {
4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
5 | // for more information about preprocessors
6 | preprocess: vitePreprocess(),
7 | };
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/svelte/tsconfig.json",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "useDefineForClassFields": true,
6 | "module": "ESNext",
7 | "resolveJsonModule": true,
8 | /**
9 | * Typecheck JS in `.svelte` and `.js` files by default.
10 | * Disable checkJs if you'd like to use dynamic types in JS.
11 | * Note that setting allowJs false does not prevent the use
12 | * of JS in `.svelte` files.
13 | */
14 | "allowJs": true,
15 | "checkJs": true,
16 | "isolatedModules": true
17 | },
18 | "include": [
19 | "src/**/*.d.ts",
20 | "src/**/*.ts",
21 | "src/**/*.js",
22 | "src/**/*.svelte",
23 | "src/constants.cjs"
24 | ],
25 | "references": [{ "path": "./tsconfig.node.json" }]
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node"
6 | },
7 | "include": ["vite.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { svelte } from "@sveltejs/vite-plugin-svelte";
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [svelte()],
7 | base: "./",
8 | });
9 |
--------------------------------------------------------------------------------