diff --git a/.forgejo/workflows/dependency-builds.yml b/.forgejo/workflows/dependency-builds.yml index 2ae48fe..fdc778f 100644 --- a/.forgejo/workflows/dependency-builds.yml +++ b/.forgejo/workflows/dependency-builds.yml @@ -4,6 +4,7 @@ on: pull_request: env: + NIX_CONFIG: experimental-features = nix-command flakes FORGEJO_TOKEN: "" GITHUB_TOKEN: "" @@ -13,6 +14,7 @@ jobs: name: Build web runs-on: nixos steps: + - run: nix profile add nixpkgs#nodejs_24 - uses: https://data.forgejo.org/actions/checkout@v4 with: persist-credentials: false @@ -26,6 +28,7 @@ jobs: name: Build desktop runs-on: nixos steps: + - run: nix profile add nixpkgs#nodejs_24 - uses: https://data.forgejo.org/actions/checkout@v4 with: persist-credentials: false @@ -51,6 +54,7 @@ jobs: name: Build mobile runs-on: nixos steps: + - run: nix profile add nixpkgs#nodejs_24 - uses: https://data.forgejo.org/actions/checkout@v4 with: persist-credentials: false diff --git a/.forgejo/workflows/deploy-dev.yml b/.forgejo/workflows/deploy-dev.yml index 27252b2..0d5be50 100644 --- a/.forgejo/workflows/deploy-dev.yml +++ b/.forgejo/workflows/deploy-dev.yml @@ -12,6 +12,9 @@ jobs: build-web: runs-on: nixos steps: + - name: Install node + run: nix profile add nixpkgs#nodejs_24 + - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -36,6 +39,9 @@ jobs: build-mobile: runs-on: nixos steps: + - name: Install node + run: nix profile add nixpkgs#nodejs_24 + - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -56,6 +62,8 @@ jobs: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }} run: | + nix profile add nixpkgs#gnused + set -euo pipefail if [ -z "$KEYSTORE_BASE64" ]; then @@ -120,6 +128,9 @@ jobs: matrix: target: [linux] steps: + - name: Install node + run: nix profile add nixpkgs#nodejs_24 + - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -170,6 +181,9 @@ jobs: runs-on: nixos needs: [build-web, build-mobile, build-desktop] steps: + - name: Install node + run: nix profile add nixpkgs#nodejs_24 + - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 with: diff --git a/.forgejo/workflows/deploy-prod.yml b/.forgejo/workflows/deploy-prod.yml index 0bb5887..d85ce27 100644 --- a/.forgejo/workflows/deploy-prod.yml +++ b/.forgejo/workflows/deploy-prod.yml @@ -13,6 +13,9 @@ jobs: build-web: runs-on: nixos steps: + - name: Install node + run: nix profile add nixpkgs#nodejs_24 + - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -37,6 +40,9 @@ jobs: build-mobile: runs-on: nixos steps: + - name: Install node + run: nix profile add nixpkgs#nodejs_24 + - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -57,6 +63,8 @@ jobs: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }} run: | + nix profile add nixpkgs#gnused + set -euo pipefail if [ -z "$KEYSTORE_BASE64" ]; then @@ -121,6 +129,9 @@ jobs: matrix: target: [linux] steps: + - name: Install node + run: nix profile add nixpkgs#nodejs_24 + - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -169,6 +180,9 @@ jobs: runs-on: nixos needs: [build-web, build-mobile, build-desktop] steps: + - name: Install node + run: nix profile add nixpkgs#nodejs_24 + - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 diff --git a/apps/electron/build/icons/128x128.png b/apps/electron/build/icons/128x128.png new file mode 100644 index 0000000..357e1c2 Binary files /dev/null and b/apps/electron/build/icons/128x128.png differ diff --git a/apps/electron/build/icons/128x128@2x.png b/apps/electron/build/icons/128x128@2x.png new file mode 100644 index 0000000..be55e24 Binary files /dev/null and b/apps/electron/build/icons/128x128@2x.png differ diff --git a/apps/electron/build/icons/32x32.png b/apps/electron/build/icons/32x32.png new file mode 100644 index 0000000..bc4fba4 Binary files /dev/null and b/apps/electron/build/icons/32x32.png differ diff --git a/apps/electron/build/icons/64x64.png b/apps/electron/build/icons/64x64.png new file mode 100644 index 0000000..567f4ad Binary files /dev/null and b/apps/electron/build/icons/64x64.png differ diff --git a/apps/electron/build/icons/icon.icns b/apps/electron/build/icons/icon.icns new file mode 100644 index 0000000..a7f0a1e Binary files /dev/null and b/apps/electron/build/icons/icon.icns differ diff --git a/apps/electron/build/icons/icon.ico b/apps/electron/build/icons/icon.ico new file mode 100644 index 0000000..c5c68f8 Binary files /dev/null and b/apps/electron/build/icons/icon.ico differ diff --git a/apps/electron/build/icons/icon.png b/apps/electron/build/icons/icon.png new file mode 100644 index 0000000..dad3edb Binary files /dev/null and b/apps/electron/build/icons/icon.png differ diff --git a/apps/pwa/src/runtime.tsx b/apps/pwa/src/runtime.tsx index 5d78973..21c2556 100644 --- a/apps/pwa/src/runtime.tsx +++ b/apps/pwa/src/runtime.tsx @@ -94,7 +94,15 @@ export default function PwaRuntime() { if (worker.state !== "installed") return; if (!navigator.serviceWorker.controller) { toast.success("Tensamin is ready for offline startup"); + return; } + toast("A Tensamin update is ready", { + duration: Infinity, + action: { + label: "Update", + onClick: () => worker.postMessage({ type: "SKIP_WAITING" }), + }, + }); }); }; if (registration.installing) watchWorker(registration.installing); diff --git a/mtp-type-maps b/mtp-type-maps index e044182..a297dcc 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit e044182b895da4f220d266275eebeb0f264b1dbe +Subproject commit a297dcce60bc6e84696c6a16f5fd510beb2ca643 diff --git a/packages/markdown/package.json b/packages/markdown/package.json deleted file mode 100644 index 247d715..0000000 --- a/packages/markdown/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@tensamin/markdown", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - "./text": "./src/text.tsx", - "./input": "./src/input.tsx", - "./emoji": "./src/emoji.tsx" - }, - "scripts": { - "format": "pnpm exec prettier --write .", - "lint": "eslint src", - "build": "tsc -p tsconfig.json --noEmit" - }, - "dependencies": { - "@codemirror/autocomplete": "^6.20.3", - "@codemirror/commands": "^6.10.4", - "@codemirror/lang-markdown": "^6.5.2", - "@codemirror/language": "^6.12.4", - "@codemirror/state": "^6.7.1", - "@codemirror/view": "^6.43.8", - "@methanium/ui": "*", - "@twemoji/api": "^17.0.3", - "emojibase-data": "^17.0.0", - "lucide-react": "^1.30.0", - "react": "^19.2.8", - "react-dom": "^19.2.8" - } -} diff --git a/packages/markdown/src/emoji.tsx b/packages/markdown/src/emoji.tsx deleted file mode 100644 index c0eb397..0000000 --- a/packages/markdown/src/emoji.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import twemoji from "@twemoji/api"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui"; -import { resolveEmoji } from "./emojiData"; - -export { - emojis, - findEmojiShortcodes, - normalizeShortcode, - resolveEmoji, - searchEmojis, -} from "./emojiData"; -export type { EmojiDefinition } from "./emojiData"; - -export function getEmojiUrl(shortcode: string): string | undefined { - const emoji = resolveEmoji(shortcode); - return emoji ? `${twemoji.base}svg/${emoji.hexcode}.svg` : undefined; -} - -export default function Emoji({ - className = "h-6 w-6", - shortcode, - tooltip = true, -}: { - className?: string; - shortcode: string; - tooltip?: boolean; -}) { - const emoji = resolveEmoji(shortcode); - if (!emoji) return {shortcode}; - - const image = ( - {emoji.shortcode} - ); - - if (!tooltip) return image; - - return ( - - - {emoji.shortcode} - - ); -} diff --git a/packages/markdown/src/emojiData.ts b/packages/markdown/src/emojiData.ts deleted file mode 100644 index 158b5b0..0000000 --- a/packages/markdown/src/emojiData.ts +++ /dev/null @@ -1,94 +0,0 @@ -import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json"; - -export type EmojiDefinition = { - aliases: readonly string[]; - hexcode: string; - name: string; - shortcode: string; -}; - -function normalizeName(value: string) { - return value - .trim() - .replace(/^:+|:+$/g, "") - .toLowerCase(); -} - -export const emojis: readonly EmojiDefinition[] = Object.entries( - shortcodeData as Record, -).map(([hexcode, value]) => { - const aliases = Array.isArray(value) ? value : [value]; - const name = aliases[0]; - - return { - aliases, - hexcode: hexcode.toLowerCase().replaceAll("_", "-"), - name, - shortcode: `:${name}:`, - }; -}); - -const emojiByName = new Map(); -for (const emoji of emojis) { - for (const alias of emoji.aliases) { - emojiByName.set(normalizeName(alias), emoji); - } -} - -export function resolveEmoji(value: string): EmojiDefinition | undefined { - return emojiByName.get(normalizeName(value)); -} - -export function normalizeShortcode(value: string): string | undefined { - return resolveEmoji(value)?.shortcode; -} - -export function findEmojiShortcodes(value: string) { - const matches: Array<{ - emoji: EmojiDefinition; - from: number; - to: number; - }> = []; - let searchFrom = 0; - - while (searchFrom < value.length) { - const from = value.indexOf(":", searchFrom); - if (from === -1) break; - - const candidate = value.slice(from).match(/^:([a-z0-9_+-]+):/i); - if (!candidate) { - searchFrom = from + 1; - continue; - } - - const emoji = resolveEmoji(candidate[1]); - if (!emoji) { - // The closing colon may also open the next valid shortcode. - searchFrom = from + candidate[0].length - 1; - continue; - } - - const to = from + candidate[0].length; - matches.push({ emoji, from, to }); - searchFrom = to; - } - - return matches; -} - -export function searchEmojis(query: string): EmojiDefinition[] { - const normalizedQuery = normalizeName(query); - if (!normalizedQuery) return [...emojis]; - - return emojis - .map((emoji) => { - const names = emoji.aliases.map(normalizeName); - const exact = names.includes(normalizedQuery); - const prefix = names.some((name) => name.startsWith(normalizedQuery)); - const contains = names.some((name) => name.includes(normalizedQuery)); - return { emoji, rank: exact ? 0 : prefix ? 1 : contains ? 2 : 3 }; - }) - .filter(({ rank }) => rank < 3) - .sort((a, b) => a.rank - b.rank || a.emoji.name.localeCompare(b.emoji.name)) - .map(({ emoji }) => emoji); -} diff --git a/packages/markdown/src/input.tsx b/packages/markdown/src/input.tsx deleted file mode 100644 index e16c84d..0000000 --- a/packages/markdown/src/input.tsx +++ /dev/null @@ -1,850 +0,0 @@ -import { markdown } from "@codemirror/lang-markdown"; -import { syntaxTree } from "@codemirror/language"; -import { - acceptCompletion, - autocompletion, - completionStatus, - pickedCompletion, - startCompletion, - type Completion, - type CompletionContext, - type CompletionResult, -} from "@codemirror/autocomplete"; -import { - EditorState, - EditorSelection, - Annotation, - Compartment, - Prec, - Transaction, - type Extension, - type Range, - type SelectionRange, -} from "@codemirror/state"; -import { - Decoration, - EditorView, - keymap, - placeholder, - ViewPlugin, - WidgetType, - type DecorationSet, - type KeyBinding, - type ViewUpdate, -} from "@codemirror/view"; -import { - defaultKeymap, - history, - historyKeymap, - indentWithTab, -} from "@codemirror/commands"; -import { useEffect, useRef } from "react"; -import type { CSSProperties } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import { collectInlineRanges, ensureMarkdownStyles } from "./markdown"; -import Emoji, { - findEmojiShortcodes, - getEmojiUrl, - resolveEmoji, - searchEmojis, -} from "./emoji"; - -export const MAX_RENDERED_EMOJI_OPTIONS = 100; - -export type InputController = { - focus: () => void; - hasFocus: () => boolean; - insertText: (text: string) => void; -}; - -export type InputProps = { - ref?: HTMLDivElement; - placeholder?: string; - value: string; - setValue: (value: string) => void; - onSubmit?: () => void; - invertEnterBehavior?: boolean; - styled?: boolean; - fontSize?: CSSProperties["fontSize"]; - paddingX?: CSSProperties["padding"]; - paddingY?: CSSProperties["padding"]; - className?: string; - emojiFrequencies?: Readonly>; - onEmojiSelect?: (shortcode: string) => void; - autoFocus?: boolean; - onControllerChange?: (controller: InputController | null) => void; -}; - -function toCssLength(value: CSSProperties["padding"]): string | undefined { - if (value === undefined) { - return undefined; - } - - return typeof value === "number" ? `${value}px` : value; -} - -function toCssPadding( - vertical: CSSProperties["padding"], - horizontal: CSSProperties["padding"], - styled: boolean, -): string { - const defaultVertical = styled ? "0.25rem" : "0"; - const defaultHorizontal = styled ? "0.625rem" : "0"; - - return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`; -} - -const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" }); -const strongDecoration = Decoration.mark({ class: "tm-md-strong" }); -const emDecoration = Decoration.mark({ class: "tm-md-em" }); -const delDecoration = Decoration.mark({ class: "tm-md-del" }); -const codeDecoration = Decoration.mark({ class: "tm-md-code" }); -const linkDecoration = Decoration.mark({ class: "tm-md-link" }); -const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" }); -const externalValueSync = Annotation.define(); -const widgetRoots = new WeakMap(); - -type EmojiRange = { - from: number; - shortcode: string; - to: number; - url: string; -}; - -class EmojiWidget extends WidgetType { - readonly shortcode: string; - readonly url: string; - - constructor(shortcode: string, url: string) { - super(); - this.shortcode = shortcode; - this.url = url; - } - - eq(other: EmojiWidget) { - return other.shortcode === this.shortcode && other.url === this.url; - } - - toDOM() { - const container = document.createElement("span"); - const root = createRoot(container); - root.render( - , - ); - widgetRoots.set(container, root); - return container; - } - - destroy(dom: HTMLElement) { - widgetRoots.get(dom)?.unmount(); - widgetRoots.delete(dom); - } - - ignoreEvent() { - return true; - } -} - -function codeRanges(state: EditorState) { - const ranges: Array<{ from: number; to: number }> = []; - - syntaxTree(state).iterate({ - enter(node) { - if ( - node.name === "InlineCode" || - node.name === "FencedCode" || - node.name === "CodeBlock" - ) { - ranges.push({ from: node.from, to: node.to }); - return false; - } - }, - }); - - return ranges; -} - -export function findEmojiRanges(state: EditorState): EmojiRange[] { - const document = state.doc.toString(); - const excluded = codeRanges(state); - const ranges: EmojiRange[] = []; - - for (const match of findEmojiShortcodes(document)) { - const { from, to } = match; - const inCode = excluded.some((range) => from < range.to && to > range.from); - const emoji = inCode ? undefined : match.emoji; - const url = emoji ? getEmojiUrl(emoji.shortcode) : undefined; - - if (emoji && url) { - ranges.push({ from, shortcode: emoji.shortcode, to, url }); - } - } - - return ranges; -} - -class EmojiPluginValue { - decorations: DecorationSet; - ranges: EmojiRange[]; - - constructor(view: EditorView) { - this.ranges = findEmojiRanges(view.state); - this.decorations = this.buildDecorations(); - } - - update(update: ViewUpdate) { - if ( - update.docChanged || - syntaxTree(update.startState) !== syntaxTree(update.state) - ) { - this.ranges = findEmojiRanges(update.state); - this.decorations = this.buildDecorations(); - } - } - - private buildDecorations() { - return Decoration.set( - this.ranges.map((range) => - Decoration.replace({ - inclusive: false, - widget: new EmojiWidget(range.shortcode, range.url), - }).range(range.from, range.to), - ), - true, - ); - } -} - -const emojiDecorations = ViewPlugin.fromClass(EmojiPluginValue, { - decorations: (instance) => instance.decorations, - provide: (plugin) => - EditorView.atomicRanges.of( - (view) => view.plugin(plugin)?.decorations ?? Decoration.none, - ), -}); - -function deleteEmoji(view: EditorView, direction: "backward" | "forward") { - const ranges = view.plugin(emojiDecorations)?.ranges ?? []; - const deletions: Array<{ from: number; to: number }> = []; - - for (const selection of view.state.selection.ranges) { - if (selection.empty) { - const emoji = ranges.find((range) => - direction === "backward" - ? selection.from > range.from && selection.from <= range.to - : selection.from >= range.from && selection.from < range.to, - ); - if (emoji) deletions.push({ from: emoji.from, to: emoji.to }); - continue; - } - - let from = selection.from; - let to = selection.to; - let changed = false; - - for (const emoji of ranges) { - if (from < emoji.to && to > emoji.from) { - from = Math.min(from, emoji.from); - to = Math.max(to, emoji.to); - changed = true; - } - } - - if (changed) deletions.push({ from, to }); - } - - if (deletions.length === 0) return false; - - const merged = deletions - .sort((a, b) => a.from - b.from) - .reduce>((result, deletion) => { - const previous = result.at(-1); - if (previous && deletion.from <= previous.to) { - previous.to = Math.max(previous.to, deletion.to); - } else { - result.push({ ...deletion }); - } - return result; - }, []); - - view.dispatch({ - changes: merged.map((range) => ({ from: range.from, to: range.to })), - selection: EditorSelection.cursor(merged[0].from), - scrollIntoView: true, - userEvent: direction === "backward" ? "delete.backward" : "delete.forward", - }); - return true; -} - -/** - * Builds markdown styling decorations every time the document or cursor selection changes. - * Token delimiters are hidden unless the cursor is currently intersecting that token range. - */ -const markdownDecorations = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - - constructor(view: EditorView) { - this.decorations = buildDecorations(view); - } - - update(update: ViewUpdate) { - if (update.docChanged || update.selectionSet || update.viewportChanged) { - this.decorations = buildDecorations(update.view); - } - } - }, - { - decorations: (instance: { decorations: DecorationSet }) => - instance.decorations, - }, -); - -/** - * Executes Input. - * @param props Parameter props. - * @returns unknown. - */ -export default function Input(props: InputProps) { - ensureMarkdownStyles(); - - const shellClassName = props.styled - ? "min-h-8 w-full min-w-0 rounded-lg border border-input bg-transparent text-base transition-colors outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40" - : ""; - - const elementRef = useRef(null); - const viewRef = useRef(undefined); - const setValueRef = useRef(props.setValue); - const onSubmitRef = useRef(props.onSubmit); - const onEmojiSelectRef = useRef( - props.onEmojiSelect, - ); - const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior)); - const completionCompartmentRef = useRef(null); - completionCompartmentRef.current ??= new Compartment(); - const completionCompartment = completionCompartmentRef.current; - - useEffect(() => { - setValueRef.current = props.setValue; - onSubmitRef.current = props.onSubmit; - onEmojiSelectRef.current = props.onEmojiSelect; - invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior); - }, [ - props.onEmojiSelect, - props.onSubmit, - props.invertEnterBehavior, - props.setValue, - ]); - - useEffect(() => { - if (!elementRef.current) return; - - const state = EditorState.create({ - doc: props.value, - extensions: createEditorExtensions( - (value) => { - setValueRef.current(value); - }, - () => props.placeholder, - () => invertEnterBehaviorRef.current, - () => onSubmitRef.current?.(), - completionCompartment, - props.emojiFrequencies, - (shortcode) => onEmojiSelectRef.current?.(shortcode), - ), - }); - - viewRef.current = new EditorView({ - state, - parent: elementRef.current, - }); - props.onControllerChange?.({ - focus: () => viewRef.current?.contentDOM.focus({ preventScroll: true }), - hasFocus: () => viewRef.current?.hasFocus ?? false, - insertText: (text) => { - const editor = viewRef.current; - if (!editor) return; - editor.dispatch({ - ...editor.state.replaceSelection(text), - annotations: Transaction.userEvent.of("input.type"), - scrollIntoView: true, - }); - }, - }); - if (props.autoFocus) viewRef.current.focus(); - - return () => { - props.onControllerChange?.(null); - viewRef.current?.destroy(); - viewRef.current = undefined; - }; - // Run once to initialize/destroy the editor instance. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const editor = viewRef.current; - if (!editor) return; - - const next = props.value; - const current = editor.state.doc.toString(); - - if (next === current) return; - - editor.dispatch({ - changes: { - from: 0, - to: current.length, - insert: next, - }, - annotations: [ - externalValueSync.of(true), - Transaction.addToHistory.of(false), - ], - filter: false, - }); - }, [props.value]); - - useEffect(() => { - const editor = viewRef.current; - const compartment = completionCompartmentRef.current; - if (!editor || !compartment) return; - - const wasActive = completionStatus(editor.state) === "active"; - editor.dispatch({ - effects: compartment.reconfigure( - createEmojiAutocomplete(props.emojiFrequencies, (shortcode) => - onEmojiSelectRef.current?.(shortcode), - ), - ), - }); - if (wasActive) startCompletion(editor); - }, [props.emojiFrequencies]); - - return ( -
- ); -} - -/** - * Executes createEditorExtensions. - * @param onChange Parameter onChange. - * @param getPlaceholder Parameter getPlaceholder. - * @param getInvertEnterBehavior Parameter getInvertEnterBehavior. - * @param onSubmit Parameter onSubmit. - * @returns Extension[]. - */ -function createEditorExtensions( - onChange: (value: string) => void, - getPlaceholder: () => string | undefined, - getInvertEnterBehavior: () => boolean, - onSubmit: () => void, - completionCompartment: Compartment, - emojiFrequencies: Readonly> | undefined, - onEmojiSelect: (shortcode: string) => void, -): Extension[] { - const editorKeymap = [ - ...defaultKeymap, - ...historyKeymap, - indentWithTab, - ] as unknown as readonly KeyBinding[]; - - const customEnterKeymap = keymap.of([ - { - key: "Shift-Enter", - run: (view) => { - if (completionStatus(view.state) === "active") { - return acceptCompletion(view); - } - if (!getInvertEnterBehavior()) { - return false; - } - - onSubmit(); - return true; - }, - }, - { - key: "Enter", - run: (view) => { - if (completionStatus(view.state) === "active") { - return acceptCompletion(view); - } - if (getInvertEnterBehavior()) { - return false; - } - - onSubmit(); - return true; - }, - }, - ]); - const completionTabKeymap = keymap.of([ - { - key: "Tab", - run: (view) => - completionStatus(view.state) === "active" - ? acceptCompletion(view) - : false, - }, - ]); - const emojiDeletionKeymap = keymap.of([ - { - key: "Backspace", - run: (view) => deleteEmoji(view, "backward"), - }, - { - key: "Delete", - run: (view) => deleteEmoji(view, "forward"), - }, - ]); - - return [ - history(), - markdown(), - completionCompartment.of( - createEmojiAutocomplete(emojiFrequencies, onEmojiSelect), - ), - emojiDecorations, - keymap.of(editorKeymap), - Prec.highest(completionTabKeymap), - Prec.highest(emojiDeletionKeymap), - Prec.highest(customEnterKeymap), - EditorView.lineWrapping, - placeholder(getPlaceholder() ?? ""), - EditorView.updateListener.of((update: ViewUpdate) => { - if (!update.docChanged) return; - if ( - update.transactions.some( - (transaction) => transaction.annotation(externalValueSync) === true, - ) - ) { - return; - } - onChange(update.state.doc.toString()); - }), - EditorView.theme({ - "&": { - fontSize: "inherit", - }, - "&.cm-editor": { - width: "100%", - }, - }), - EditorView.editorAttributes.of({ - class: "tm-md-editor", - spellcheck: "true", - "aria-label": "Markdown input", - }), - markdownDecorations, - ]; -} - -function createEmojiAutocomplete( - frequencies: Readonly> | undefined, - onEmojiSelect: (shortcode: string) => void, -) { - return autocompletion({ - activateOnTyping: true, - addToOptions: [ - { - position: 20, - render(completion) { - const container = document.createElement("span"); - createRoot(container).render( - , - ); - return container; - }, - }, - ], - maxRenderedOptions: MAX_RENDERED_EMOJI_OPTIONS, - override: [createEmojiCompletionSource(frequencies, onEmojiSelect)], - }); -} - -function normalizedFrequencies( - frequencies: Readonly> | undefined, -) { - const normalized = new Map(); - for (const [value, frequency] of Object.entries(frequencies ?? {})) { - const shortcode = resolveEmoji(value)?.shortcode; - if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) continue; - normalized.set(shortcode, (normalized.get(shortcode) ?? 0) + frequency); - } - return normalized; -} - -export function createEmojiCompletionSource( - frequencies?: Readonly>, - onEmojiSelect: (shortcode: string) => void = () => undefined, -) { - const normalized = normalizedFrequencies(frequencies); - const maxFrequency = Math.max(0, ...normalized.values()); - - return (context: CompletionContext): CompletionResult | null => { - const token = context.matchBefore(/:[a-z0-9_+-]*$/i); - if (!token) return null; - if ( - codeRanges(context.state).some( - (range) => token.from < range.to && token.to > range.from, - ) - ) { - return null; - } - - const characterBefore = context.state.sliceDoc( - Math.max(0, token.from - 1), - token.from, - ); - if (characterBefore && /[a-z0-9_]/i.test(characterBefore)) return null; - - const query = token.text.slice(1).toLowerCase(); - const options: Completion[] = searchEmojis(query) - .map((emoji) => { - const aliases = emoji.aliases.map((alias) => alias.toLowerCase()); - const matchedAlias = - aliases.find((alias) => alias === query) ?? - aliases.find((alias) => alias.startsWith(query)) ?? - aliases.find((alias) => alias.includes(query)) ?? - emoji.name; - const relevance = !query - ? 0 - : matchedAlias === query - ? 80 - : matchedAlias.startsWith(query) - ? 40 - : 0; - const frequency = normalized.get(emoji.shortcode) ?? 0; - const usage = - maxFrequency > 0 - ? (15 * Math.log1p(frequency)) / Math.log1p(maxFrequency) - : 0; - - return { - apply(view, completion, from, to) { - view.dispatch({ - annotations: pickedCompletion.of(completion), - changes: { from, insert: `${emoji.shortcode} `, to }, - selection: EditorSelection.cursor( - from + emoji.shortcode.length + 1, - ), - }); - onEmojiSelect(emoji.shortcode); - }, - boost: relevance + usage, - displayLabel: emoji.shortcode, - label: `:${matchedAlias}:`, - type: "text", - frequency, - relevance, - } satisfies Completion & { frequency: number; relevance: number }; - }) - .sort( - (a, b) => - b.relevance - a.relevance || - b.frequency - a.frequency || - (a.displayLabel ?? a.label).localeCompare(b.displayLabel ?? b.label), - ); - - return { - from: token.from, - options, - validFor: /^:[a-z0-9_+-]*$/i, - }; - }; -} - -export const emojiCompletionSource = createEmojiCompletionSource(); - -/** - * Executes buildDecorations. - * @param view Parameter view. - * @returns DecorationSet. - */ -function buildDecorations(view: EditorView): DecorationSet { - const builder: Range[] = []; - const selections = view.state.selection.ranges.map( - (range: SelectionRange) => ({ - from: range.from, - to: range.to, - }), - ); - - let codeFenceOpen = false; - - for ( - let lineNumber = 1; - lineNumber <= view.state.doc.lines; - lineNumber += 1 - ) { - const line = view.state.doc.line(lineNumber); - const text = line.text; - const lineFrom = line.from; - const trimmed = text.trim(); - - const fence = text.match(/^```\s*([^`]*)$/); - if (fence) { - const ticksStart = lineFrom + text.indexOf("```"); - const ticksEnd = ticksStart + 3; - addHiddenToken(builder, selections, { from: ticksStart, to: ticksEnd }); - if (trimmed.length > 3) { - addHiddenToken(builder, selections, { - from: ticksEnd, - to: line.to, - }); - } - codeFenceOpen = !codeFenceOpen; - continue; - } - - if (codeFenceOpen) { - builder.push(codeLineDecoration.range(lineFrom)); - continue; - } - - const heading = text.match(/^(#{1,6})\s+/); - if (heading) { - const markerLength = heading[0].length; - addHiddenToken(builder, selections, { - from: lineFrom, - to: lineFrom + markerLength, - }); - - const level = heading[1].length; - const headingClass = Decoration.mark({ - class: `tm-md-heading tm-md-h${String(level)}`, - }); - const contentFrom = lineFrom + markerLength; - if (contentFrom < line.to) { - builder.push(headingClass.range(contentFrom, line.to)); - } - } - - const quote = text.match(/^>\s?/); - if (quote) { - addHiddenToken(builder, selections, { - from: lineFrom, - to: lineFrom + quote[0].length, - }); - } - - const unordered = text.match(/^(\s*)([-+*])\s+(?:\[( |x|X)\]\s+)?/); - if (unordered) { - const markerStart = lineFrom + unordered[1].length; - const markerEnd = markerStart + unordered[2].length + 1; - addHiddenToken(builder, selections, { from: markerStart, to: markerEnd }); - - const checkbox = unordered[0].match(/\[( |x|X)\]\s+$/); - if (checkbox) { - const checkboxStart = lineFrom + unordered[0].lastIndexOf("["); - addHiddenToken(builder, selections, { - from: checkboxStart, - to: checkboxStart + checkbox[0].length, - }); - } - } - - const ordered = text.match(/^(\s*)(\d+\.)\s+/); - if (ordered) { - const markerStart = lineFrom + ordered[1].length; - addHiddenToken(builder, selections, { - from: markerStart, - to: markerStart + ordered[2].length + 1, - }); - } - - if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(trimmed)) { - if (lineFrom < line.to) { - builder.push( - Decoration.mark({ class: "tm-md-hr" }).range(lineFrom, line.to), - ); - } - continue; - } - - const tableSeparator = /^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/.test( - text, - ); - if (tableSeparator) { - if (lineFrom < line.to) { - builder.push( - Decoration.mark({ class: "tm-md-del" }).range(lineFrom, line.to), - ); - } - continue; - } - - const { styleRanges, tokenRanges } = collectInlineRanges(text, lineFrom); - - for (const range of styleRanges) { - if (range.from >= range.to) continue; - - if (range.className === "tm-md-strong") { - builder.push(strongDecoration.range(range.from, range.to)); - } else if (range.className === "tm-md-em") { - builder.push(emDecoration.range(range.from, range.to)); - } else if (range.className === "tm-md-del") { - builder.push(delDecoration.range(range.from, range.to)); - } else if (range.className === "tm-md-code") { - builder.push(codeDecoration.range(range.from, range.to)); - } else if (range.className === "tm-md-link") { - builder.push(linkDecoration.range(range.from, range.to)); - } - } - - for (const token of tokenRanges) { - addHiddenToken(builder, selections, token); - } - } - - return Decoration.set(builder, true); -} - -/** - * Keeps markdown syntax visible only when user selection intersects the token. - * This preserves cursor predictability and cross-token selection while still hiding syntax during reading. - */ -function addHiddenToken( - builder: Range[], - selections: ReadonlyArray<{ from: number; to: number }>, - token: { - from: number; - to: number; - }, -): void { - if (token.from >= token.to) return; - - const overlapsSelection = selections.some((selection) => { - const selectionFrom = Math.min(selection.from, selection.to); - const selectionTo = Math.max(selection.from, selection.to); - - if (selectionFrom === selectionTo) { - return selectionFrom >= token.from && selectionFrom <= token.to; - } - - return selectionFrom < token.to && selectionTo > token.from; - }); - - if (overlapsSelection) return; - builder.push(hiddenTokenDecoration.range(token.from, token.to)); -} diff --git a/packages/markdown/src/markdown.tsx b/packages/markdown/src/markdown.tsx deleted file mode 100644 index cd2a210..0000000 --- a/packages/markdown/src/markdown.tsx +++ /dev/null @@ -1,815 +0,0 @@ -import { - Fragment, - useEffect, - useRef, - useState, - type ReactElement, - type ReactNode, -} from "react"; -import Emoji from "./emoji"; -import { findEmojiShortcodes } from "./emojiData"; -import { Check } from "lucide-react"; - -type InlineNode = - | { type: "text"; value: string } - | { type: "emoji"; shortcode: string } - | { type: "strong"; value: string } - | { type: "em"; value: string } - | { type: "del"; value: string } - | { type: "code"; value: string } - | { type: "link"; label: string; href: string } - | { type: "image"; alt: string; src: string }; - -type InlineDecorationRange = { - from: number; - to: number; - className: string; -}; - -type InlineTokenRange = { - from: number; - to: number; -}; - -type ListItem = { - text: string; - checked: boolean | null; -}; - -type TableBlock = { - type: "table"; - headers: string[]; - rows: string[][]; -}; - -type MarkdownBlock = - | { - type: "paragraph"; - text: string; - } - | { - type: "heading"; - level: number; - text: string; - } - | { - type: "hr"; - } - | { - type: "blockquote"; - text: string; - } - | { - type: "code"; - language: string; - code: string; - } - | { - type: "list"; - ordered: boolean; - items: ListItem[]; - } - | TableBlock; - -const INLINE_TOKEN_REGEX = - /!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(? - - - ); -} - -function CopyableCode({ - block = false, - language, - value, -}: { - block?: boolean; - language?: string; - value: string; -}) { - const [copied, setCopied] = useState(false); - const copiedTimer = useRef | undefined>( - undefined, - ); - - useEffect( - () => () => { - clearTimeout(copiedTimer.current); - }, - [], - ); - - async function copy() { - await navigator.clipboard.writeText(value); - setCopied(true); - clearTimeout(copiedTimer.current); - copiedTimer.current = setTimeout(() => setCopied(false), 1200); - } - - const code = ( - void copy()} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - void copy(); - }} - > - {value} - - ); - - if (block) { - return ( -
-
{code}
- -
- ); - } - - return ( - <> - {code} - - - ); -} - -/** - * Executes parseInlineNodes. - * @param input Parameter input. - * @returns InlineNode[]. - */ -export function parseInlineNodes(input: string): InlineNode[] { - const nodes: InlineNode[] = []; - - let cursor = 0; - let match = INLINE_TOKEN_REGEX.exec(input); - - while (match) { - const index = match.index; - const raw = match[0]; - - if (index > cursor) { - nodes.push(...parseEmojiText(input.slice(cursor, index))); - } - - if (match[1] !== undefined && match[2] !== undefined) { - nodes.push({ type: "image", alt: match[1], src: normalizeUrl(match[2]) }); - } else if (match[3] !== undefined && match[4] !== undefined) { - nodes.push({ - type: "link", - label: match[3], - href: normalizeUrl(match[4]), - }); - } else if (match[5] !== undefined) { - nodes.push({ type: "code", value: match[5] }); - } else if (match[6] !== undefined) { - nodes.push({ type: "del", value: match[6] }); - } else if (match[7] !== undefined || match[8] !== undefined) { - nodes.push({ type: "strong", value: match[7] ?? match[8] ?? "" }); - } else if (match[9] !== undefined || match[10] !== undefined) { - nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" }); - } else { - nodes.push(...parseEmojiText(raw)); - } - - cursor = index + raw.length; - match = INLINE_TOKEN_REGEX.exec(input); - } - - if (cursor < input.length) { - nodes.push(...parseEmojiText(input.slice(cursor))); - } - - INLINE_TOKEN_REGEX.lastIndex = 0; - return nodes; -} - -export function parseEmojiText(input: string): InlineNode[] { - const nodes: InlineNode[] = []; - let cursor = 0; - - for (const match of findEmojiShortcodes(input)) { - if (match.from > cursor) { - nodes.push({ type: "text", value: input.slice(cursor, match.from) }); - } - - nodes.push({ type: "emoji", shortcode: match.emoji.shortcode }); - cursor = match.to; - } - - if (cursor < input.length) { - nodes.push({ type: "text", value: input.slice(cursor) }); - } - - return nodes; -} - -/** - * Executes collectInlineRanges. - * @param input Parameter input. - * @param offset Parameter offset. - * @returns { - styleRanges: InlineDecorationRange[]; - tokenRanges: InlineTokenRange[]; -}. - */ -export function collectInlineRanges( - input: string, - offset = 0, -): { - styleRanges: InlineDecorationRange[]; - tokenRanges: InlineTokenRange[]; -} { - const styleRanges: InlineDecorationRange[] = []; - const tokenRanges: InlineTokenRange[] = []; - - let match = INLINE_TOKEN_REGEX.exec(input); - - while (match) { - const raw = match[0]; - const start = offset + match.index; - const end = start + raw.length; - - if (match[1] !== undefined && match[2] !== undefined) { - const openLength = 2; - const closeLength = raw.endsWith(")") ? 1 : 0; - - const imageEnd = start + openLength + match[1].length; - tokenRanges.push({ from: start, to: start + openLength }); - tokenRanges.push({ from: imageEnd, to: imageEnd + 1 }); - - const srcStart = imageEnd + 1; - const srcEnd = end - closeLength; - tokenRanges.push({ from: srcStart, to: srcStart + 1 }); - tokenRanges.push({ from: srcEnd, to: srcEnd + closeLength }); - } else if (match[3] !== undefined && match[4] !== undefined) { - const label = match[3]; - const labelStart = start + 1; - const labelEnd = labelStart + label.length; - - tokenRanges.push({ from: start, to: start + 1 }); - tokenRanges.push({ from: labelEnd, to: labelEnd + 1 }); - tokenRanges.push({ from: labelEnd + 1, to: labelEnd + 2 }); - tokenRanges.push({ from: end - 1, to: end }); - - styleRanges.push({ - from: labelStart, - to: labelEnd, - className: "tm-md-link", - }); - } else if (match[5] !== undefined) { - const codeStart = start + 1; - const codeEnd = end - 1; - - tokenRanges.push({ from: start, to: start + 1 }); - tokenRanges.push({ from: end - 1, to: end }); - styleRanges.push({ - from: codeStart, - to: codeEnd, - className: "tm-md-code", - }); - } else if (match[6] !== undefined) { - const contentStart = start + 2; - const contentEnd = end - 2; - - tokenRanges.push({ from: start, to: start + 2 }); - tokenRanges.push({ from: end - 2, to: end }); - styleRanges.push({ - from: contentStart, - to: contentEnd, - className: "tm-md-del", - }); - } else if (match[7] !== undefined || match[8] !== undefined) { - const contentStart = start + 2; - const contentEnd = end - 2; - - tokenRanges.push({ from: start, to: start + 2 }); - tokenRanges.push({ from: end - 2, to: end }); - styleRanges.push({ - from: contentStart, - to: contentEnd, - className: "tm-md-strong", - }); - } else if (match[9] !== undefined || match[10] !== undefined) { - const contentStart = start + 1; - const contentEnd = end - 1; - - tokenRanges.push({ from: start, to: start + 1 }); - tokenRanges.push({ from: end - 1, to: end }); - styleRanges.push({ - from: contentStart, - to: contentEnd, - className: "tm-md-em", - }); - } - - match = INLINE_TOKEN_REGEX.exec(input); - } - - INLINE_TOKEN_REGEX.lastIndex = 0; - return { styleRanges, tokenRanges }; -} - -/** - * Executes parseMarkdownBlocks. - * @param markdown Parameter markdown. - * @returns MarkdownBlock[]. - */ -export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] { - const lines = markdown.replace(/\r\n/g, "\n").split("\n"); - const blocks: MarkdownBlock[] = []; - - let index = 0; - - while (index < lines.length) { - const line = lines[index]; - - if (!line.trim()) { - index += 1; - continue; - } - - const codeFence = line.match(/^```\s*([^`]*)$/); - if (codeFence) { - const language = (codeFence[1] ?? "").trim(); - const codeLines: string[] = []; - index += 1; - - while (index < lines.length && !/^```\s*$/.test(lines[index])) { - codeLines.push(lines[index]); - index += 1; - } - - if (index < lines.length) { - index += 1; - } - - blocks.push({ type: "code", language, code: codeLines.join("\n") }); - continue; - } - - if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(line.trim())) { - blocks.push({ type: "hr" }); - index += 1; - continue; - } - - const heading = line.match(/^(#{1,6})\s+(.+)$/); - if (heading) { - blocks.push({ - type: "heading", - level: heading[1].length, - text: heading[2], - }); - index += 1; - continue; - } - - const quote = line.match(/^>\s?(.*)$/); - if (quote) { - const quoteLines: string[] = [quote[1]]; - index += 1; - - while (index < lines.length) { - const next = lines[index].match(/^>\s?(.*)$/); - if (!next) break; - quoteLines.push(next[1]); - index += 1; - } - - blocks.push({ type: "blockquote", text: quoteLines.join("\n") }); - continue; - } - - const tableCandidate = readTable(lines, index); - if (tableCandidate) { - blocks.push(tableCandidate.block); - index = tableCandidate.nextIndex; - continue; - } - - const unordered = line.match(/^\s*[-*+]\s+(.*)$/); - const ordered = line.match(/^\s*\d+\.\s+(.*)$/); - if (unordered || ordered) { - const orderedList = Boolean(ordered); - const items: ListItem[] = []; - - while (index < lines.length) { - const current = lines[index]; - const match = orderedList - ? current.match(/^\s*\d+\.\s+(.*)$/) - : current.match(/^\s*[-*+]\s+(.*)$/); - - if (!match) break; - - const task = match[1].match(/^\[( |x|X)\]\s+(.*)$/); - if (task) { - items.push({ - text: task[2], - checked: task[1].toLowerCase() === "x", - }); - } else { - items.push({ text: match[1], checked: null }); - } - - index += 1; - } - - blocks.push({ type: "list", ordered: orderedList, items }); - continue; - } - - const paragraphLines = [line]; - index += 1; - - while ( - index < lines.length && - lines[index].trim() && - !/^(#{1,6})\s+/.test(lines[index]) && - !/^```\s*/.test(lines[index]) && - !/^>\s?/.test(lines[index]) && - !/^\s*[-*+]\s+/.test(lines[index]) && - !/^\s*\d+\.\s+/.test(lines[index]) && - !/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(lines[index].trim()) - ) { - paragraphLines.push(lines[index]); - index += 1; - } - - blocks.push({ type: "paragraph", text: paragraphLines.join("\n") }); - } - - return blocks; -} - -/** - * Executes renderInline. - * @param nodes Parameter nodes. - * @returns React.ReactNode[]. - */ -function renderInline(nodes: InlineNode[]): ReactNode[] { - return nodes.map((node, index) => { - if (node.type === "text") { - return node.value; - } - - if (node.type === "emoji") { - return ( - - ); - } - - if (node.type === "strong") { - return ( - - {renderInline(parseEmojiText(node.value))} - - ); - } - - if (node.type === "em") { - return ( - - {renderInline(parseEmojiText(node.value))} - - ); - } - - if (node.type === "del") { - return ( - - {renderInline(parseEmojiText(node.value))} - - ); - } - - if (node.type === "code") { - return ; - } - - if (node.type === "link") { - return ( - - {renderInline(parseEmojiText(node.label))} - - ); - } - - return ( - {node.alt} - ); - }); -} - -/** - * Executes renderBlocks. - * @param blocks Parameter blocks. - * @returns React.ReactElement. - */ -export function renderBlocks(blocks: MarkdownBlock[]): ReactElement { - return ( - <> - {blocks.map((block, blockIndex) => { - if (block.type === "heading") { - const className = `tm-md-heading tm-md-h${String(block.level)}`; - if (block.level === 1) - return ( -

- {renderInline(parseInlineNodes(block.text))} -

- ); - if (block.level === 2) - return ( -

- {renderInline(parseInlineNodes(block.text))} -

- ); - if (block.level === 3) - return ( -

- {renderInline(parseInlineNodes(block.text))} -

- ); - if (block.level === 4) - return ( -

- {renderInline(parseInlineNodes(block.text))} -

- ); - if (block.level === 5) - return ( -
- {renderInline(parseInlineNodes(block.text))} -
- ); - return ( -
- {renderInline(parseInlineNodes(block.text))} -
- ); - } - - if (block.type === "blockquote") { - return ( -
- {block.text.split("\n").map((line, lineIndex) => ( -

{renderInline(parseInlineNodes(line))}

- ))} -
- ); - } - - if (block.type === "code") { - return ( - - ); - } - - if (block.type === "list") { - const Tag = block.ordered ? "ol" : "ul"; - return ( - - {block.items.map((item, itemIndex) => ( -
  • - {item.checked !== null ? ( - - ) : null} - {renderInline(parseInlineNodes(item.text))} -
  • - ))} -
    - ); - } - - if (block.type === "table") { - return ( -
    - - - - {block.headers.map((header, headerIndex) => ( - - ))} - - - - {block.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - - ))} - - ))} - -
    - {renderInline(parseInlineNodes(header))} -
    - {renderInline(parseInlineNodes(cell))} -
    -
    - ); - } - - if (block.type === "hr") { - return
    ; - } - - return ( -

    - {block.text.split("\n").map((line, lineIndex) => ( - - {lineIndex > 0 ?
    : null} - {renderInline(parseInlineNodes(line))} -
    - ))} -

    - ); - })} - - ); -} - -/** - * Executes normalizeUrl. - * @param input Parameter input. - * @returns string. - */ -function normalizeUrl(input: string): string { - const value = input.trim(); - if (/^(https?:|mailto:|tel:|\/)/i.test(value)) { - return value; - } - - return "#"; -} - -/** - * Executes splitTableRow. - * @param row Parameter row. - * @returns string[]. - */ -function splitTableRow(row: string): string[] { - const cleaned = row.trim().replace(/^\|/, "").replace(/\|$/, ""); - return cleaned.split("|").map((cell) => cell.trim()); -} - -/** - * Executes readTable. - * @param lines Parameter lines. - * @param index Parameter index. - * @returns { block: TableBlock; nextIndex: number } | null. - */ -function readTable( - lines: string[], - index: number, -): { block: TableBlock; nextIndex: number } | null { - const header = lines[index] ?? ""; - const separator = lines[index + 1] ?? ""; - - if (!header.includes("|") || !separator.includes("|")) { - return null; - } - - const separatorCells = splitTableRow(separator); - const isSeparator = separatorCells.every((cell) => /^:?-{3,}:?$/.test(cell)); - if (!isSeparator) { - return null; - } - - const headers = splitTableRow(header); - const rows: string[][] = []; - let cursor = index + 2; - - while (cursor < lines.length && lines[cursor].includes("|")) { - rows.push(splitTableRow(lines[cursor])); - cursor += 1; - } - - return { - block: { type: "table", headers, rows }, - nextIndex: cursor, - }; -} - -const markdownStyles = ` -.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; } -.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; } -.tm-md-h1 { font-size: 1.65rem; } -.tm-md-h2 { font-size: 1.45rem; } -.tm-md-h3 { font-size: 1.25rem; } -.tm-md-h4 { font-size: 1.1rem; } -.tm-md-h5 { font-size: 1rem; } -.tm-md-h6 { font-size: 0.95rem; opacity: 0.9; } -.tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; } -.tm-md-blockquote p { margin: 0.2rem 0; } -.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: var(--muted); overflow-x: auto; } -.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.87em; cursor: pointer; } -.tm-md-code { padding: 0.08rem 0.32rem; border: 1px solid var(--border); border-radius: 0.28rem; background: var(--muted); } -.tm-md-codeblock { display: block; } -.tm-md-code:focus-visible, .tm-md-codeblock:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } -.tm-md-strong { font-weight: 700; } -.tm-md-em { font-style: italic; } -.tm-md-del { text-decoration: line-through; } -.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; } -.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; } -.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; } -.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; } -.tm-md-li { margin: 0.2rem 0; } -.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; } -.tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; } -.tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; } -.tm-md-table th, .tm-md-table td { padding: 0.4rem 0.5rem; text-align: left; } -.tm-md-table th { background: var(--muted); font-weight: 600; } -.tm-md-hr { margin: 0.55rem 0; } - -.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); } -.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; } -.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; } -.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); } -.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; } -.cm-editor.tm-md-editor .cm-line { padding: 0; color: var(--foreground); } -.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; } -.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; } -.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; } -.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; } -.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; } -.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; } -.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { background: transparent; } -.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { border-radius: 9999px; background: var(--border); } -.cm-tooltip.cm-tooltip-autocomplete > ul > li { display: flex; min-height: 2.25rem; align-items: center; border-radius: calc(var(--radius) * 0.8); padding: 0.3rem 0.5rem; color: var(--popover-foreground); } -.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover, -.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--accent); color: var(--accent-foreground); } -.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { display: none; } -.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { overflow: hidden; text-overflow: ellipsis; } -.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { color: inherit; text-decoration: none; font-weight: 600; } -.cm-tooltip-autocomplete .tm-md-completion-emoji { display: inline-block; width: 1.35rem; height: 1.35rem; flex: 0 0 auto; margin-right: 0.5rem; vertical-align: middle; } -`; - -/** - * Executes ensureMarkdownStyles. - * @param none This function has no parameters. - * @returns void. - */ -export function ensureMarkdownStyles(): void { - if (typeof document === "undefined") return; - - const styleId = "tensamin-markdown-styles"; - let style = document.getElementById(styleId) as HTMLStyleElement | null; - - if (!style) { - style = document.createElement("style"); - style.id = styleId; - document.head.appendChild(style); - } - - if (style.textContent !== markdownStyles) { - style.textContent = markdownStyles; - } -} diff --git a/packages/markdown/src/text.tsx b/packages/markdown/src/text.tsx deleted file mode 100644 index ca65fe0..0000000 --- a/packages/markdown/src/text.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useMemo, type CSSProperties } from "react"; - -import { - ensureMarkdownStyles, - parseMarkdownBlocks, - renderBlocks, -} from "./markdown"; - -export type TextProps = { - value: string; - fontSize?: CSSProperties["fontSize"]; -}; - -/** - * Executes Text. - * @param props Parameter props. - * @returns unknown. - */ -export default function Text(props: TextProps) { - ensureMarkdownStyles(); - - const blocks = useMemo(() => parseMarkdownBlocks(props.value), [props.value]); - const renderedBlocks = useMemo(() => renderBlocks(blocks), [blocks]); - - return ( -
    - {renderedBlocks} -
    - ); -} diff --git a/packages/markdown/todo.md b/packages/markdown/todo.md deleted file mode 100644 index b59da98..0000000 --- a/packages/markdown/todo.md +++ /dev/null @@ -1 +0,0 @@ -- Improve the Input box diff --git a/packages/markdown/tsconfig.json b/packages/markdown/tsconfig.json deleted file mode 100644 index 9714625..0000000 --- a/packages/markdown/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 107149b..0cbdd6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -459,45 +459,6 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) - packages/markdown: - dependencies: - '@codemirror/autocomplete': - specifier: ^6.20.3 - version: 6.20.3 - '@codemirror/commands': - specifier: ^6.10.4 - version: 6.11.0 - '@codemirror/lang-markdown': - specifier: ^6.5.2 - version: 6.5.2 - '@codemirror/language': - specifier: ^6.12.4 - version: 6.12.4 - '@codemirror/state': - specifier: ^6.7.1 - version: 6.7.1 - '@codemirror/view': - specifier: ^6.43.8 - version: 6.43.9 - '@methanium/ui': - specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz - version: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(emojibase@17.0.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@7.2.0)(typescript@6.0.3) - '@twemoji/api': - specifier: ^17.0.3 - version: 17.0.3 - emojibase-data: - specifier: ^17.0.0 - version: 17.0.0(emojibase@17.0.0) - lucide-react: - specifier: ^1.30.0 - version: 1.32.0(react@19.2.8) - react: - specifier: ^19.2.8 - version: 19.2.8 - react-dom: - specifier: ^19.2.8 - version: 19.2.8(react@19.2.8) - packages/mtp: dependencies: '@methanium/ui': @@ -2579,12 +2540,6 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} - '@twemoji/api@17.0.3': - resolution: {integrity: sha512-iwERjxY0QgPGVwT6b1OKG0Oa9nIfHhJw+Ij1TapTBMKTvVCU6qdXPXX/XKwxKx5QZIJW5GwELUCtw8wlaIQ2ug==} - - '@twemoji/parser@17.0.2': - resolution: {integrity: sha512-X/P7pHsGOxnrupQYUVetIeuxBGgffFu8CLwoPMMjH9CWmQvlXiCpbTW/BXxMOCWXQojgHdmgdvm6IsCqAQ5nxA==} - '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -4573,9 +4528,6 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - jsonfile@5.0.0: - resolution: {integrity: sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==} - jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -8479,15 +8431,6 @@ snapshots: minimatch: 10.2.6 path-browserify: 1.0.1 - '@twemoji/api@17.0.3': - dependencies: - '@twemoji/parser': 17.0.2 - fs-extra: 8.1.0 - jsonfile: 5.0.0 - universalify: 0.1.2 - - '@twemoji/parser@17.0.2': {} - '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -10676,12 +10619,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonfile@5.0.0: - dependencies: - universalify: 0.1.2 - optionalDependencies: - graceful-fs: 4.2.11 - jsonfile@6.2.1: dependencies: universalify: 2.0.1