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 (
-
-
-
-
- -
- Get started by editing{" "}
-
- src/app/page.tsx
-
- .
-
- -
- Save and see your changes instantly.
-
-
+ const [state, setState] = useState(State.Write);
+ const [buffer, setBuffer] = useState("");
+ const [focus, setFocus] = useState(false);
-
-
-
-
- );
+ 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() {}
+}