From c201327280c1ad1315755f27d312d00d38063742 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 31 Aug 2025 17:23:35 +0200 Subject: [PATCH] idiot protection UwU --- package.json | 12 ++-- pnpm-lock.yaml | 24 +++++++ src/app/globals.css | 25 +++---- src/app/layout.tsx | 44 ++++++------ src/app/page.tsx | 137 ++++++++++------------------------- src/lib/editor.tsx | 104 +++++++++++++++++++++++++++ src/lib/markdown.tsx | 165 +++++++++++++++++++++++++++++++++++++++++++ src/lib/theme.tsx | 21 ++++++ 8 files changed, 392 insertions(+), 140 deletions(-) create mode 100644 src/lib/editor.tsx create mode 100644 src/lib/markdown.tsx create mode 100644 src/lib/theme.tsx diff --git a/package.json b/package.json index 8e23392..c05e322 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,21 @@ "lint": "eslint" }, "dependencies": { + "clsx": "^2.1.1", + "next": "15.5.2", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.2" + "react-merge-refs": "^3.0.2" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.5.2", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f0ce4e..1e038bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + clsx: + specifier: ^2.1.1 + version: 2.1.1 next: specifier: 15.5.2 version: 15.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -17,6 +20,9 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + react-merge-refs: + specifier: ^3.0.2 + version: 3.0.2(react@19.1.0) devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -726,6 +732,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1530,6 +1540,14 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-merge-refs@3.0.2: + resolution: {integrity: sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw==} + peerDependencies: + react: '>=16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0' + peerDependenciesMeta: + react: + optional: true + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -2434,6 +2452,8 @@ snapshots: client-only@0.0.1: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3376,6 +3396,10 @@ snapshots: react-is@16.13.1: {} + react-merge-refs@3.0.2(react@19.1.0): + optionalDependencies: + react: 19.1.0 + react@19.1.0: {} reflect.getprototypeof@1.0.10: diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..ba2750e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,19 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #101010; + --foreground: #ededed; } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-inter); + --font-mono: var(--font-jetbrains-mono); } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..c3c5746 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,34 +1,38 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { + Geist, + Inter, + JetBrains_Mono as JetbrainsMono, +} from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], +const inter = Inter({ + variable: "--font-inter", }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], +const jetbrainsMono = JetbrainsMono({ + variable: "--font-jetbrains-mono", }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: { + default: "Quill", + template: "%s | Quill", + }, }; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - - {children} - - - ); + return ( + + + {children} + + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index a932894..e11f96f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,103 +1,42 @@ -import Image from "next/image"; +"use client"; + +import Editor from "@/lib/editor"; +import clsx from "clsx"; +import { useEffect, useRef, useState } from "react"; + +const State = { + Write: 0, + View: 1, +} as const; +type State = (typeof State)[keyof typeof State]; export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+ const [state, setState] = useState(State.Write); + const [buffer, setBuffer] = useState(""); + const [focus, setFocus] = useState(false); -
- - Vercel logomark - Deploy now - - - Read our docs - -
-
- -
- ); + return ( +
+
+ [untitled document] +
+
+ +
+
+ * unsaved + 542 words + writing + + unfocused + +
+
+ ); } diff --git a/src/lib/editor.tsx b/src/lib/editor.tsx new file mode 100644 index 0000000..66cfcc7 --- /dev/null +++ b/src/lib/editor.tsx @@ -0,0 +1,104 @@ +"use client"; + +import assert from "assert"; +import { get } from "http"; +import { KeyboardEvent, Ref, RefObject, useEffect, useRef } from "react"; +import { mergeRefs } from "react-merge-refs"; +import { Markdown } from "./markdown"; + +interface Props { + className?: string; + ref?: Ref; + + onFocusChange?: (focused: boolean) => void; +} + +export default function Editor({ + className = "", + ref: extRef, + onFocusChange: extFocusChange = () => void {}, +}: Props) { + const ref = useRef(null) as RefObject; + const _state = useRef({ + markdown: new Markdown(), + caret: [0, 0], + focused: false, + }); + const state = () => _state.current; + + function onSelectionChange(ev: Event) { + // console.log(caretPos()); + } + + function onKeyDown(ev: KeyboardEvent) { + ev.preventDefault(); + + if (ev.key.length !== 1) return; + + state().markdown.textAt(0).node.textContent = ev.key; + } + + useEffect(() => + document.addEventListener( + "selectionchange", + (ev) => state().focused && onSelectionChange(ev) + ) + ); + + const onFocusChange = (focused: boolean) => { + state().focused = focused; + extFocusChange(focused); + }; + + return ( +
+
onFocusChange(true)} + onBlur={() => onFocusChange(false)} + onKeyDown={onKeyDown} + /> +
+ ); +} + +// function caretPos(): ( +// | (number & { range: false }) +// | ([number, number] & { range: true }) +// ) & { [Symbol.iterator](): Generator } { +// let { +// anchorOffset: base, +// anchorNode: baseNode, +// focusOffset: focus, +// focusNode, +// } = document.getSelection()!; + +// base += +// Number.parseInt( +// baseNode?.parentElement?.getAttribute("data-range-start") ?? "0" +// ) ?? 0; +// focus += +// Number.parseInt( +// focusNode?.parentElement?.getAttribute("data-range-start") ?? "0" +// ) ?? 0; + +// const scalar = base === focus; +// const position = scalar +// ? base +// : [Math.min(base, focus), Math.max(base, focus)]; + +// return Object.assign(position, { +// range: !scalar, +// [Symbol.iterator]: function* () { +// if (Array.isArray(position)) { +// yield position[0]; +// yield position[1]; +// } else { +// yield position; +// } +// }, +// }) as ReturnType; +// } diff --git a/src/lib/markdown.tsx b/src/lib/markdown.tsx new file mode 100644 index 0000000..65e0529 --- /dev/null +++ b/src/lib/markdown.tsx @@ -0,0 +1,165 @@ +import { RefObject } from "react"; + +type Event = ((...args: T) => void) & { + on: (callback: (...args: T) => void) => void; + once: (callback: (...args: T) => void) => void; + off: (callback: (...args: T) => void) => void; +}; +function Event(): Event { + let listeners: ((...args: T) => void)[] = []; + const callable = ((...args: T) => { + for (const listener of listeners) listener(...args); + }) as Event; + + callable.on = (callback) => { + listeners.push(callback); + }; + callable.off = (callback) => { + const index = listeners.findIndex((other) => other === callback); + listeners = [ + ...listeners.slice(0, index), + ...listeners.slice(index + 1), + ]; + }; + callable.once = (callback) => { + const closure = (...args: T) => { + callback(...args); + callable.off(closure); + }; + callable.on(closure); + }; + + return callable; +} + +abstract class Node { + private _start: number; + public get start() { + return this._start; + } + protected set start(value: number) { + this._start = value; + + this.BoundChange(this._start, undefined); + } + + private _end: number; + public get end() { + return this._end; + } + protected set end(value: number) { + this._end = value; + + this.BoundChange(undefined, this._end); + } + + public readonly BoundChange: Event< + [number, undefined] | [undefined, number] + > = Event(); + + public NodeCreated: Event<[Node]> = Event(); + + public node: globalThis.Node; + public constructor(offset: number) { + this._start = offset; + this._end = offset; + + this.node = this.createElement(); + console.log(this.node); + + this.NodeCreated(this); + } + + protected abstract _textAt(position: number): Text; + public textAt(position: number): Text { + console.assert(position >= this._start && position <= this._end); + return this._textAt(position); + } + + public abstract createElement(): globalThis.Node; +} + +abstract class NodeCollection extends Node { + collection: (T | Text)[] = []; + + protected _textAt(position: number): Text { + if (this.collection.length === 0) { + const text = new Text(this.start); + + this.NodeCreated(text); + this.collection.push(text); + + this.node.appendChild(text.node); + + return text; + } + + let i; + for (i = 0; i < this.collection.length; i++) { + const node = this.collection[i]; + if (node.start > position) break; + if (node.end <= position) continue; + return node.textAt(position); + } + + const prev = this.collection[i - 1]; + console.assert(prev); + + if (prev.end === position) { + const text = new Text(position); + + this.NodeCreated(text); + this.collection.push(text); + + this.node.insertBefore(text.node, prev.node.nextSibling); + + return text; + } + + return prev.textAt(position); + } +} + +export class Text extends Node { + private text: string = ""; + public TextChange: Event<[string]> = Event(); + + public constructor(offset: number) { + super(offset); + } + + _textAt(_: number): Text { + return this; + } + + public createElement(): globalThis.Node { + const node = document.createTextNode(""); + + return node; + } +} + +export class Paragraph extends NodeCollection { + public createElement(): globalThis.Node { + const node = document.createElement("p"); + + return node; + } +} + +export class Markdown extends NodeCollection { + public lookup: Map = new Map(); + + public constructor(element: HTMLDivElement) { + super(0); + this.NodeCreated.on((node) => { + console.log("created node:", node); + + this.lookup.set(node.node, node); + }); + } + + public createElement(): globalThis.Node { + return 0 as never; + } +} diff --git a/src/lib/theme.tsx b/src/lib/theme.tsx new file mode 100644 index 0000000..59ada3a --- /dev/null +++ b/src/lib/theme.tsx @@ -0,0 +1,21 @@ +const DefaultTheme = { + background: "#ededed", + contrast: "#f1f1f1", +} as const; + +/** @deprecated */ +export interface Theme extends Record {} +export class Theme { + private constructor() { + for (const key in DefaultTheme) { + this[key as keyof Theme] = + DefaultTheme[key as keyof typeof DefaultTheme]; + } + } + + public static default() { + return new Theme(); + } + + public static parse() {} +}