idiot protection UwU

This commit is contained in:
2025-08-31 17:23:35 +02:00
parent 5420a034fd
commit c201327280
8 changed files with 392 additions and 140 deletions

View File

@@ -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
View File

@@ -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:

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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
View 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
View 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
View 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() {}
}