idiot protection UwU
This commit is contained in:
12
package.json
12
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"
|
||||
}
|
||||
}
|
||||
|
||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
@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;
|
||||
}
|
||||
--font-sans: var(--font-inter);
|
||||
--font-mono: var(--font-jetbrains-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
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({
|
||||
@@ -25,7 +29,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} antialiased min-h-screen min-w-screen relative flex h-0 w-0`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
127
src/app/page.tsx
127
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 (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
const [state, setState] = useState<State>(State.Write);
|
||||
const [buffer, setBuffer] = useState("");
|
||||
const [focus, setFocus] = useState(false);
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
return (
|
||||
<main className="editor font-mono flex relative w-full h-full flex-col">
|
||||
<div className="info-line text-white/50 m-4 mb-0">
|
||||
<span>[untitled document]</span>
|
||||
</div>
|
||||
<div className="content-area grow relative flex h-0 overflow-clip">
|
||||
<Editor
|
||||
className="px-4 overflow-y-auto w-full"
|
||||
onFocusChange={setFocus}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
</div>
|
||||
<div className="status-line m-4 mt-0 border-t border-white/40 py-4 pb-0 flex flex-row gap-3 *:pl-3 *:border-l *:first:border-0 text-white/50 *:border-white/30">
|
||||
<span>* unsaved</span>
|
||||
<span>542 words</span>
|
||||
<span>writing</span>
|
||||
<span
|
||||
className="text-red-400 data-[focus=true]:collapse"
|
||||
data-focus={focus}
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
unfocused
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
104
src/lib/editor.tsx
Normal file
104
src/lib/editor.tsx
Normal file
@@ -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<HTMLDivElement>;
|
||||
|
||||
onFocusChange?: (focused: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Editor({
|
||||
className = "",
|
||||
ref: extRef,
|
||||
onFocusChange: extFocusChange = () => void {},
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null) as RefObject<HTMLDivElement>;
|
||||
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<HTMLDivElement>) {
|
||||
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 (
|
||||
<div>
|
||||
<div
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
ref={mergeRefs([ref, extRef])}
|
||||
className={`focus:outline-0 whitespace-pre inline ${className}`}
|
||||
onFocus={() => onFocusChange(true)}
|
||||
onBlur={() => onFocusChange(false)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// function caretPos(): (
|
||||
// | (number & { range: false })
|
||||
// | ([number, number] & { range: true })
|
||||
// ) & { [Symbol.iterator](): Generator<number> } {
|
||||
// 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<typeof caretPos>;
|
||||
// }
|
||||
165
src/lib/markdown.tsx
Normal file
165
src/lib/markdown.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { RefObject } from "react";
|
||||
|
||||
type Event<T extends unknown[]> = ((...args: T) => void) & {
|
||||
on: (callback: (...args: T) => void) => void;
|
||||
once: (callback: (...args: T) => void) => void;
|
||||
off: (callback: (...args: T) => void) => void;
|
||||
};
|
||||
function Event<T extends unknown[]>(): Event<T> {
|
||||
let listeners: ((...args: T) => void)[] = [];
|
||||
const callable = ((...args: T) => {
|
||||
for (const listener of listeners) listener(...args);
|
||||
}) as Event<T>;
|
||||
|
||||
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<T extends Node> 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<Node> {
|
||||
public createElement(): globalThis.Node {
|
||||
const node = document.createElement("p");
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
export class Markdown extends NodeCollection<Paragraph> {
|
||||
public lookup: Map<globalThis.Node, Node> = 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;
|
||||
}
|
||||
}
|
||||
21
src/lib/theme.tsx
Normal file
21
src/lib/theme.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
const DefaultTheme = {
|
||||
background: "#ededed",
|
||||
contrast: "#f1f1f1",
|
||||
} as const;
|
||||
|
||||
/** @deprecated */
|
||||
export interface Theme extends Record<keyof typeof DefaultTheme, string> {}
|
||||
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() {}
|
||||
}
|
||||
Reference in New Issue
Block a user