From 842835bce0022d9dea52b54446af021721f6cc88 Mon Sep 17 00:00:00 2001 From: RTa-technology Date: Sat, 27 May 2023 21:02:27 +0900 Subject: [PATCH] feat: component --- modules/codemirror/README.md | 3 + modules/codemirror/package.json | 32 + modules/codemirror/src/editor-field.ts | 205 + modules/codemirror/src/gutters.ts | 12 + modules/codemirror/src/indent-hack.ts | 62 + modules/codemirror/src/index.ts | 9 + modules/codemirror/src/languages.ts | 21 + modules/codemirror/src/misc.ts | 122 + modules/codemirror/src/print-tree.ts | 38 + modules/codemirror/src/svelte/svelte-dom.ts | 118 + .../src/svelte/svelte-lifecycle-element.ts | 33 + modules/codemirror/src/svelte/svelte-panel.ts | 92 + modules/codemirror/tests/adapters.ts | 20 + modules/codemirror/tsconfig.json | 7 + modules/comlink/README.md | 1 + modules/comlink/package.json | 17 + modules/comlink/src/index.ts | 183 + modules/comlink/tsconfig.json | 7 + modules/dom/README.md | 1 + modules/dom/package.json | 17 + modules/dom/src/base-button.ts | 65 + modules/dom/src/base-tooltip-button.ts | 99 + modules/dom/src/custom-elements.ts | 43 + modules/dom/src/focus.ts | 135 + modules/dom/src/gesture.ts | 261 + modules/dom/src/held.ts | 56 + modules/dom/src/hover.ts | 97 + modules/dom/src/index.ts | 15 + modules/dom/src/key-handling.ts | 90 + modules/dom/src/observe.ts | 55 + modules/dom/src/scrolling.ts | 82 + modules/dom/src/svelte-adapters.ts | 82 + modules/dom/src/swipe.ts | 184 + modules/dom/src/useragent.ts | 31 + modules/dom/src/validation.ts | 33 + modules/dom/src/visibility.ts | 83 + modules/dom/tests/focus.ts | 133 + modules/dom/tests/misc.ts | 122 + modules/dom/tsconfig.json | 7 + modules/prism/README.md | 113 + modules/prism/package.json | 19 + modules/prism/src/ftml.ts | 159 + modules/prism/src/index.ts | 17 + modules/prism/src/worker.ts | 71 + modules/prism/tests/prism.skipped | 38 + modules/prism/tsconfig.json | 7 + modules/prism/vendor/prism-langs.js | 7796 +++++++++++++++++ modules/prism/vendor/prism-min.js | 3 + modules/prism/vendor/prism-svelte.js | 148 + modules/prism/vendor/prism.js | 1409 +++ modules/sheaf/README.md | 3 + modules/sheaf/package.json | 35 + .../sheaf/src/components/CodeDisplay.svelte | 100 + .../src/components/MorphingWikitext.svelte | 105 + .../sheaf/src/components/PaneEditor.svelte | 30 + .../src/components/PaneEditorTopbar.svelte | 183 + .../sheaf/src/components/PanePreview.svelte | 142 + .../sheaf/src/components/SettingsMenu.svelte | 89 + modules/sheaf/src/components/Sheaf.svelte | 117 + .../sheaf/src/components/SheafPanel.svelte | 65 + modules/sheaf/src/context.ts | 36 + modules/sheaf/src/core.ts | 91 + modules/sheaf/src/extensions/bindings.ts | 38 + modules/sheaf/src/extensions/keymap.ts | 34 + modules/sheaf/src/extensions/theme.ts | 117 + modules/sheaf/src/index.ts | 2 + modules/sheaf/src/state.ts | 181 + modules/sheaf/tsconfig.json | 7 + modules/util/README.md | 15 + modules/util/package.json | 17 + modules/util/src/decorators.ts | 49 + modules/util/src/html.ts | 43 + modules/util/src/index.ts | 608 ++ modules/util/src/pref.ts | 163 + modules/util/src/timeout.ts | 188 + modules/util/tests/wj-util.ts | 191 + modules/util/tsconfig.json | 7 + .../vendor/request-idle-callback-polyfill.js | 211 + package.json | 6 + pnpm-lock.yaml | 126 + resources/css/main.scss | 2 +- src/css/code.css | 39 + src/css/init.css | 1 + src/css/wikidot.css | 4 + src/main.ts | 1 + vite.config.js | 3 +- 86 files changed, 15500 insertions(+), 2 deletions(-) create mode 100644 modules/codemirror/README.md create mode 100644 modules/codemirror/package.json create mode 100644 modules/codemirror/src/editor-field.ts create mode 100644 modules/codemirror/src/gutters.ts create mode 100644 modules/codemirror/src/indent-hack.ts create mode 100644 modules/codemirror/src/index.ts create mode 100644 modules/codemirror/src/languages.ts create mode 100644 modules/codemirror/src/misc.ts create mode 100644 modules/codemirror/src/print-tree.ts create mode 100644 modules/codemirror/src/svelte/svelte-dom.ts create mode 100644 modules/codemirror/src/svelte/svelte-lifecycle-element.ts create mode 100644 modules/codemirror/src/svelte/svelte-panel.ts create mode 100644 modules/codemirror/tests/adapters.ts create mode 100644 modules/codemirror/tsconfig.json create mode 100644 modules/comlink/README.md create mode 100644 modules/comlink/package.json create mode 100644 modules/comlink/src/index.ts create mode 100644 modules/comlink/tsconfig.json create mode 100644 modules/dom/README.md create mode 100644 modules/dom/package.json create mode 100644 modules/dom/src/base-button.ts create mode 100644 modules/dom/src/base-tooltip-button.ts create mode 100644 modules/dom/src/custom-elements.ts create mode 100644 modules/dom/src/focus.ts create mode 100644 modules/dom/src/gesture.ts create mode 100644 modules/dom/src/held.ts create mode 100644 modules/dom/src/hover.ts create mode 100644 modules/dom/src/index.ts create mode 100644 modules/dom/src/key-handling.ts create mode 100644 modules/dom/src/observe.ts create mode 100644 modules/dom/src/scrolling.ts create mode 100644 modules/dom/src/svelte-adapters.ts create mode 100644 modules/dom/src/swipe.ts create mode 100644 modules/dom/src/useragent.ts create mode 100644 modules/dom/src/validation.ts create mode 100644 modules/dom/src/visibility.ts create mode 100644 modules/dom/tests/focus.ts create mode 100644 modules/dom/tests/misc.ts create mode 100644 modules/dom/tsconfig.json create mode 100644 modules/prism/README.md create mode 100644 modules/prism/package.json create mode 100644 modules/prism/src/ftml.ts create mode 100644 modules/prism/src/index.ts create mode 100644 modules/prism/src/worker.ts create mode 100644 modules/prism/tests/prism.skipped create mode 100644 modules/prism/tsconfig.json create mode 100644 modules/prism/vendor/prism-langs.js create mode 100644 modules/prism/vendor/prism-min.js create mode 100644 modules/prism/vendor/prism-svelte.js create mode 100644 modules/prism/vendor/prism.js create mode 100644 modules/sheaf/README.md create mode 100644 modules/sheaf/package.json create mode 100644 modules/sheaf/src/components/CodeDisplay.svelte create mode 100644 modules/sheaf/src/components/MorphingWikitext.svelte create mode 100644 modules/sheaf/src/components/PaneEditor.svelte create mode 100644 modules/sheaf/src/components/PaneEditorTopbar.svelte create mode 100644 modules/sheaf/src/components/PanePreview.svelte create mode 100644 modules/sheaf/src/components/SettingsMenu.svelte create mode 100644 modules/sheaf/src/components/Sheaf.svelte create mode 100644 modules/sheaf/src/components/SheafPanel.svelte create mode 100644 modules/sheaf/src/context.ts create mode 100644 modules/sheaf/src/core.ts create mode 100644 modules/sheaf/src/extensions/bindings.ts create mode 100644 modules/sheaf/src/extensions/keymap.ts create mode 100644 modules/sheaf/src/extensions/theme.ts create mode 100644 modules/sheaf/src/index.ts create mode 100644 modules/sheaf/src/state.ts create mode 100644 modules/sheaf/tsconfig.json create mode 100644 modules/util/README.md create mode 100644 modules/util/package.json create mode 100644 modules/util/src/decorators.ts create mode 100644 modules/util/src/html.ts create mode 100644 modules/util/src/index.ts create mode 100644 modules/util/src/pref.ts create mode 100644 modules/util/src/timeout.ts create mode 100644 modules/util/tests/wj-util.ts create mode 100644 modules/util/tsconfig.json create mode 100644 modules/util/vendor/request-idle-callback-polyfill.js create mode 100644 src/css/code.css diff --git a/modules/codemirror/README.md b/modules/codemirror/README.md new file mode 100644 index 0000000..d9fb131 --- /dev/null +++ b/modules/codemirror/README.md @@ -0,0 +1,3 @@ +# @wikijump/codemirror + +A package containing consolidated exports for CodeMirror. diff --git a/modules/codemirror/package.json b/modules/codemirror/package.json new file mode 100644 index 0000000..4f92016 --- /dev/null +++ b/modules/codemirror/package.json @@ -0,0 +1,32 @@ +{ + "name": "@wikijump/codemirror", + "license": "agpl-3.0-or-later", + "description": "Helper package that consolidates CodeMirror exports.", + "version": "0.0.0", + "keywords": [ + "wikijump" + ], + "private": true, + "scripts": {}, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@codemirror/autocomplete": "^0.20.1", + "@codemirror/commands": "^0.20.0", + "@codemirror/lang-css": "^0.20.0", + "@codemirror/lang-html": "^0.20.0", + "@codemirror/language": "^0.20.2", + "@codemirror/language-data": "^0.20.0", + "@codemirror/legacy-modes": "^0.20.0", + "@codemirror/lint": "^0.20.2", + "@codemirror/search": "^0.20.1", + "@codemirror/state": "^0.20.0", + "@codemirror/view": "^0.20.6", + "@lezer/common": "^0.16.0", + "@lezer/lr": "^0.16.3", + "@lezer/highlight": "^0.16.0", + "@wikijump/components": "workspace:*", + "@wikijump/util": "workspace:*", + "svelte": "^3.48.0" + } +} diff --git a/modules/codemirror/src/editor-field.ts b/modules/codemirror/src/editor-field.ts new file mode 100644 index 0000000..92447bc --- /dev/null +++ b/modules/codemirror/src/editor-field.ts @@ -0,0 +1,205 @@ +import { + Compartment, + EditorState, + StateEffect, + StateField, + Transaction, + type Extension +} from "@codemirror/state" +import { EditorView } from "@codemirror/view" +import { writable, type Writable } from "svelte/store" + +export interface EditorFieldOpts { + /** The default value for the field when it is created. */ + default: T + + /** + * Function that runs when the view is updated. This does not replace the + * `update` method the `StateField` object is created with, instead this + * is ran after that method determines the field's value. + * + * If a value that isn't undefined is returned, that'll replace the field's value. + */ + update?: (value: T, transaction: Transaction, changed: boolean) => T | undefined | void + + /** Allows for providing values to facets, or even just purely adding extensions. */ + provide?: (field: StateField) => Extension + + /** + * Function that, if given, will reconfigure a `Compartment` with the + * returned `Extension` when this field updates. Return null to indicate + * no extensions, return false to indicate that the extensions should not + * actually be reconfigured. + */ + reconfigure?: (value: T, last: T | null) => Extension | null | false +} + +/** + * Smart handler for adding fields to CodeMirror editor instances. + * + * @typeParam T - The value that the field contains. + */ +export class EditorField { + /** + * The `StateEffect` for the field. This is a unique object that is + * solely capable of modifying the field's value. + */ + private declare effect + + /** + * The `StateField` for the field. This is a object that describes the + * behavior of the field, such as how it is created or updated. + */ + private declare field + + /** A compartment for extension reconfiguration. */ + private declare compartment + + /** Function that determines what extensions should be given to the compartment. */ + private declare reconfigure?: (value: T, last: T | null) => Extension | null | false + + /** + * The extension that mounts this field to an editor. Additionally, + * providing this field allows for simply treating any instance of an + * `EditorField` as an extension. + */ + declare extension: Extension + + /** + * A mapping of `EditorView` objects to observables, for the purpose of + * tracking their existence and for updating them. + */ + private observableMap = new Map>() + + /** @param opts - Configuration for this field. A default state is required. */ + constructor(opts: EditorFieldOpts) { + this.effect = StateEffect.define() + + this.field = StateField.define({ + create: () => opts.default, + provide: opts.provide, + update: (value, tr) => { + let out = value + let changed = false + + // check if this transaction has our effect(s) + for (const effect of tr.effects) { + if (effect.is(this.effect)) { + out = effect.value + changed = true + } + } + + // run the optional update function, mutate output if needed + if (opts.update) { + const result = opts.update(value, tr, changed) + if (result !== undefined) out = result + } + + return out + } + }) + + if (opts.reconfigure) { + this.compartment = new Compartment() + this.reconfigure = opts.reconfigure + const defaultExtensions = this.reconfigure(opts.default, null) + this.extension = [this.field, this.compartment.of(defaultExtensions || [])] + } else { + this.extension = this.field + } + } + + /** + * Gets the current value for this field. + * + * @param state - The `EditorState` to source the value from. + */ + get(state: EditorState): T + /** + * Gets the current value for this field. + * + * @param view - The `EditorView` to source the value from. + */ + get(view: EditorView): T + get(source: EditorView | EditorState): T { + if (source instanceof EditorView) { + return source.state.field(this.field) + } else { + return source.field(this.field) + } + } + + /** + * Sets the value for this field. + * + * @param view - The `EditorView` to dispatch the change to. + * @param state - The value to set the field to. + */ + set(view: EditorView, state: T) { + const from = this.get(view) + if (from === state) return + + view.dispatch({ effects: this.effect.of(state) }) + + const to = this.get(view) + + if (from !== to) { + // reconfigure compartment + if (this.reconfigure && this.compartment) { + const extensions = this.reconfigure(to, from) + if (extensions !== false) { + view.dispatch({ effects: this.compartment.reconfigure(extensions ?? []) }) + } + } + + // inform observers + if (this.observableMap.size) { + for (const [obView, observable] of this.observableMap) { + if (obView === view) observable.set(to) + } + } + } + } + + /** + * Returns an extension that mounts this field, but using a different + * creation value. + * + * @param value - The value to set the field to on creation. + */ + of(value: T) { + return this.field.init(() => value) + } + + /** + * Returns a Svelte-compatible observable for reactively reading and + * updating this field. If a observable already exists for the view + * given, it'll simply be reused. This means it is safe to call this + * method repeatedly for a view. + * + * @param view - The `EditorView` to attach the observable to. + */ + bind(view: EditorView): Writable { + if (this.observableMap.has(view)) return this.observableMap.get(view)! + + // create an observer that automatically adds and + // deletes itself from the observableMap + const observable = writable(this.get(view), () => { + this.observableMap.set(view, observable) + return () => void this.observableMap.delete(view) + }) + + // create a handler around that observable so that we update the editor state + return { + subscribe: observable.subscribe, + // subscribers get informed when the state updates, + // so we don't want to do a double update by informing them again here + set: value => this.set(view, value), + update: updater => { + const value = updater(this.get(view)) + this.set(view, value) + } + } + } +} diff --git a/modules/codemirror/src/gutters.ts b/modules/codemirror/src/gutters.ts new file mode 100644 index 0000000..c72159d --- /dev/null +++ b/modules/codemirror/src/gutters.ts @@ -0,0 +1,12 @@ +import { foldGutter } from "@codemirror/language" +import { lineNumbers } from "@codemirror/view" +import { EditorField } from "./editor-field" + +/** + * `EditorField` extension that enables a field that controls whether or + * not the editor gutter is mounted. + */ +export const Gutters = new EditorField({ + default: true, + reconfigure: state => (state ? [lineNumbers(), foldGutter()] : null) +}) diff --git a/modules/codemirror/src/indent-hack.ts b/modules/codemirror/src/indent-hack.ts new file mode 100644 index 0000000..ffb77c2 --- /dev/null +++ b/modules/codemirror/src/indent-hack.ts @@ -0,0 +1,62 @@ +import { EditorState, RangeSetBuilder, type Line } from "@codemirror/state" +import { + Decoration, + ViewPlugin, + type DecorationSet, + type EditorView, + type ViewUpdate +} from "@codemirror/view" + +const WHITESPACE_REGEX = /^\s+/ + +/** + * Extension that makes it so that lines which wrap onto new lines preserve + * their indentation. Called a "hack" because this is done through CSS + * trickery, and not through any sort of complex DOM arrangement. + */ +export const IndentHack = ViewPlugin.fromClass( + class { + decorations: DecorationSet + constructor(view: EditorView) { + this.decorations = generateIndentDecorations(view) + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = generateIndentDecorations(update.view) + } + } + }, + { decorations: v => v.decorations } +) + +function generateIndentDecorations(view: EditorView) { + // get every line of the visible ranges + const lines = new Set() + for (const { from, to } of view.visibleRanges) { + for (let pos = from; pos <= to; ) { + let line = view.state.doc.lineAt(pos) + lines.add(line) + pos = line.to + 1 + } + } + + // get the indentation of every line + // and create an offset hack decoration if it has any + const tabInSpaces = " ".repeat(view.state.facet(EditorState.tabSize)) + const builder = new RangeSetBuilder() + for (const line of lines) { + const WS = WHITESPACE_REGEX.exec(line.text)?.[0] + const col = WS?.replaceAll("\t", tabInSpaces).length + if (col) { + builder.add( + line.from, + line.from, + Decoration.line({ + attributes: { style: `padding-left: ${col}ch; text-indent: -${col}ch` } + }) + ) + } + } + + return builder.finish() +} diff --git a/modules/codemirror/src/index.ts b/modules/codemirror/src/index.ts new file mode 100644 index 0000000..0f9f20c --- /dev/null +++ b/modules/codemirror/src/index.ts @@ -0,0 +1,9 @@ +export * from "./editor-field" +export * from "./gutters" +export * from "./indent-hack" +export * from "./languages" +export * from "./misc" +export * from "./print-tree" +export * from "./svelte/svelte-dom" +export * from "./svelte/svelte-lifecycle-element" +export * from "./svelte/svelte-panel" diff --git a/modules/codemirror/src/languages.ts b/modules/codemirror/src/languages.ts new file mode 100644 index 0000000..b7e31ee --- /dev/null +++ b/modules/codemirror/src/languages.ts @@ -0,0 +1,21 @@ +import type { LanguageDescription } from "@codemirror/language" +import { languages } from "@codemirror/language-data" +import { Facet } from "@codemirror/state" + +/** + * A `Facet` that holds a list of `LanguageDescription` instances. + * Languages can be added to this facet in the editor, so that a plugin may + * retrieve a list of languages in common use by the editor and its plugins. + */ +export const languageList = Facet.define() + +/** Returns an extension for every `LanguageDescription` provided. */ +export function addLanguages(...languages: LanguageDescription[]) { + return languages.map(language => languageList.of(language)) +} + +/** + * A list of extensions that adds every language from the + * `@codemirror/language-data` package into the {@link languageList} facet. + */ +export const defaultLanguages = addLanguages(...languages) diff --git a/modules/codemirror/src/misc.ts b/modules/codemirror/src/misc.ts new file mode 100644 index 0000000..a64a317 --- /dev/null +++ b/modules/codemirror/src/misc.ts @@ -0,0 +1,122 @@ +import { LanguageDescription, LanguageSupport, LRLanguage } from "@codemirror/language" +import type { EditorState, Extension, Text } from "@codemirror/state" +import type { LRParser, ParserConfig } from "@lezer/lr" +import { animationFrame, byteLength, idleCallback } from "@wikijump/util" + +const encoder = new TextEncoder() + +/** Asynchronously compiles a `Text` object into a single string. */ +export async function textValue(doc: Text) { + let out = "" + let last = 0 + + for (const str of doc) { + out += str + // throttle on 32k chunks + if (out.length - last > 32768) { + last = out.length + await animationFrame() + } + } + + return out +} + +/** Asynchronously compiles a `Text` object into a `Uint8Array` buffer. */ +export async function textBuffer(doc: Text) { + let len = 0 + let last = 0 + const buffers: Uint8Array[] = [] + + for (const str of doc) { + const buffer = encoder.encode(str) + buffers.push(buffer) + len += buffer.length + // throttle on 32k chunks + if (len - last > 32768) { + last = len + await animationFrame() + } + } + + let pos = 0 + const out = new Uint8Array(len) + await idleCallback(() => { + for (const buffer of buffers) { + out.set(buffer, pos) + pos += buffer.length + } + }) + + return out +} + +/** Efficiently gets the byte length of a `Text` object. */ +export function textByteLength(doc: Text) { + let len = 0 + for (const str of doc) len += byteLength(str) + return len +} + +/** + * Gets the "active lines" of a state. This includes any lines the user has + * a cursor on and all lines touching their selection box, if any. + */ +export function getActiveLines(state: EditorState) { + const activeLines: Set = new Set() + for (const range of state.selection.ranges) { + const lnStart = state.doc.lineAt(range.from).number + const lnEnd = state.doc.lineAt(range.to).number + if (lnStart === lnEnd) activeLines.add(lnStart - 1) + else { + const diff = lnEnd - lnStart + for (let lineNo = 0; lineNo <= diff; lineNo++) { + activeLines.add(lnStart + lineNo - 1) + } + } + } + return activeLines +} + +// this is apparently how CodeMirror does underlines, +// I figured it was just a text underline, but no, it's actually this +// kind of interesting +/** Returns a `background-image` inlined SVG string for decorations. */ +export function underline(color: string) { + if (typeof btoa !== "function") return "none" + let svg = ` + + ` + return `url('data:image/svg+xml;base64,${btoa(svg)}')` +} + +export interface CreateLezerLanguageOpts { + name: string + parser: LRParser + configure?: ParserConfig + alias?: string[] + ext?: string[] + languageData?: Record + extensions?: Extension[] +} + +export function createLezerLanguage(opts: CreateLezerLanguageOpts) { + const langDesc = Object.assign( + { name: opts.name }, + opts.alias ? { alias: opts.alias } : {}, + opts.ext ? { extensions: opts.ext } : {} + ) + const langData = { ...langDesc, ...(opts.languageData ?? {}) } + + const load = function () { + const lang = LRLanguage.define({ + parser: opts.parser.configure(opts.configure ?? {}), + languageData: langData + }) + return new LanguageSupport(lang, opts.extensions) + } + + const description = LanguageDescription.of({ ...langDesc, load: async () => load() }) + + return { load, description } +} diff --git a/modules/codemirror/src/print-tree.ts b/modules/codemirror/src/print-tree.ts new file mode 100644 index 0000000..0ed9d73 --- /dev/null +++ b/modules/codemirror/src/print-tree.ts @@ -0,0 +1,38 @@ +import type { Tree } from "@lezer/common" + +function indent(depth: number, str = " ") { + return depth > 0 ? str.repeat(depth) : "" +} + +/** Pretty-prints a Lezer tree. Doesn't log the result - just returns it. */ +export function printTree(tree: Tree, src: string) { + let output = "" + let depth = -1 + + tree.iterate({ + enter(node) { + const from = node.from + const to = node.to + const len = to - from + + depth++ + + let slice: string + if (len <= 40) slice = src.slice(from, to) + else slice = `${src.slice(from, from + 20)} ... ${src.slice(to - 20, to)}` + + slice = slice.replaceAll("\n", "\\n").replaceAll('"', '\\"') + + output += `\n${indent(depth, "ā”‚ ")}${node.name} [${from}, ${to}]: "${slice}"` + }, + + leave() { + depth-- + if (depth === 0) output += "\nā”‚" + } + }) + + output = output.replace(/\u2502\s*$/, "").trim() + + return output +} diff --git a/modules/codemirror/src/svelte/svelte-dom.ts b/modules/codemirror/src/svelte/svelte-dom.ts new file mode 100644 index 0000000..7368d7f --- /dev/null +++ b/modules/codemirror/src/svelte/svelte-dom.ts @@ -0,0 +1,118 @@ +import type { EditorView, ViewUpdate } from "@codemirror/view" +import type { SvelteComponent } from "svelte" +import { LifecycleElement } from "./svelte-lifecycle-element" + +export interface EditorSvelteComponentProps { + /** + * The {@link EditorView} the component is mounted to. This can be + * undefined if the component was created with no view. In that instance, + * the component will still track lifecycle but won't be able to interact + * with the editor. + */ + view: EditorView | undefined + /** The last {@link ViewUpdate} of the editor. */ + update: ViewUpdate | undefined + /** Calls `$destroy()` on the component. */ + unmount: () => void +} + +export interface EditorSvelteComponentInstance { + /** DOM container that holds the Svelte component. */ + dom: LifecycleElement + /** Function that needs to be called whenever the view updates. */ + update: (update: ViewUpdate) => void + /** + * Clones this instance so that it may be used in contexts where reusing + * the node isn't safe. + */ + clone: () => EditorSvelteComponentInstance +} + +export interface EditorSvelteComponentOpts { + /** Props to pass to the component on mount. */ + pass?: Record + /** + * Callback called immediately after the component is mounted. + * + * @param component - The component that was just mounted. + */ + mount?: (component: T) => void + /** + * Callback called immediately before the component is unmounted. + * + * @param component - The component that is about to be unmounted. + */ + unmount?: (component: T) => void +} + +/** + * Handler class for using Svelte components in the CodeMirror DOM. + * + * The component is provided with two props: + * + * - `view` + * - `update` + * - `unmount` + * + * You can see the types of these props in the + * {@link EditorSvelteComponentProps} interface. + * + * @see {@link EditorSvelteComponentProps} + */ +export class EditorSvelteComponent { + /** @param component - The Svelte component to be mounted. */ + constructor(public component: T) {} + + /** + * Creates the DOM container and lifecycle functions needed to mount a + * Svelte component into CodeMirror structures, such as panels and tooltips. + * + * @param view - The {@link EditorView} that the component will be attached to. + * @param opts - {@link EditorSvelteComponentOpts} + */ + create( + view?: EditorView, + opts: EditorSvelteComponentOpts> = {} + ): EditorSvelteComponentInstance { + let component: SvelteComponent | null = null + + const unmount = (dom: LifecycleElement) => { + // prevent unmount from being called twice, if something else called this function + dom._unmount = undefined + if (opts.unmount) opts.unmount(component as InstanceType) + if (component) component.$destroy() + component = null + } + + const mount = (dom: LifecycleElement) => { + const svelteUnmount = () => unmount(dom) + + component = new this.component({ + target: dom, + intro: true, + props: view + ? { view, unmount: svelteUnmount, update: undefined, ...opts.pass } + : { unmount: svelteUnmount, ...opts.pass } + }) + + if (opts.mount) opts.mount(component as InstanceType) + + // start listening to unmounting + dom._unmount = unmount + } + + const update = (update: ViewUpdate) => { + if (!component) return + const view = update.view + component.$set({ view, update }) + } + + const dom = new LifecycleElement(mount) + + const clone = () => { + return this.create(view, opts) + } + + return { dom, update, clone } + } +} diff --git a/modules/codemirror/src/svelte/svelte-lifecycle-element.ts b/modules/codemirror/src/svelte/svelte-lifecycle-element.ts new file mode 100644 index 0000000..54d6b11 --- /dev/null +++ b/modules/codemirror/src/svelte/svelte-lifecycle-element.ts @@ -0,0 +1,33 @@ +/** + * Custom element that is used to detect when Svelte components should be + * mounted or unmounted. + */ +export class LifecycleElement extends HTMLElement { + /** Element/tag name that this element is registered with. */ + static tag = "svelte-cm-lifecycle" + + /** + * @param _mount - Function to be called whenever this element is mounted. + * @param _unmount - Function to be called whenever this element is unmounted. + */ + constructor( + public _mount?: (dom: LifecycleElement) => void, + public _unmount?: (dom: LifecycleElement) => void + ) { + super() + } + + connectedCallback() { + if (this._mount) this._mount(this) + this.dispatchEvent(new CustomEvent("connected")) + } + + disconnectedCallback() { + if (this._unmount) this._unmount(this) + this.dispatchEvent(new CustomEvent("disconnected")) + } +} + +if (!customElements.get(LifecycleElement.tag)) { + customElements.define(LifecycleElement.tag, LifecycleElement) +} diff --git a/modules/codemirror/src/svelte/svelte-panel.ts b/modules/codemirror/src/svelte/svelte-panel.ts new file mode 100644 index 0000000..fff604c --- /dev/null +++ b/modules/codemirror/src/svelte/svelte-panel.ts @@ -0,0 +1,92 @@ +import type { Extension } from "@codemirror/state" +import { EditorView, showPanel, type Panel } from "@codemirror/view" +import type { SvelteComponent } from "svelte" +import { EditorField } from "../editor-field" +import { + EditorSvelteComponent, + type EditorSvelteComponentOpts, + type EditorSvelteComponentProps +} from "./svelte-dom" + +/** + * The props provided to a {@link EditorSveltePanel} component. + * + * @see {@link EditorSveltePanel} + */ +export interface EditorSveltePanelProps extends EditorSvelteComponentProps { + /** Calls `$destroy()` on the component and then unmounts the panel. */ + unmount: () => void +} + +export interface EditorSveltePanelOpts + extends EditorSvelteComponentOpts { + /** If true, the panel will be mounted on the top of the editor. */ + top?: boolean +} + +/** + * A panel that uses a Svelte component to render its contents. + * + * The component is provided with three props: + * + * - `view` + * - `update` + * - `unmount` + * + * You can see the types of these props in the + * {@link EditorSveltePanelProps} interface. + * + * @see {@link EditorSveltePanelProps} + */ +export class EditorSveltePanel { + /** + * Extension that mounts the panel to the editor. You don't really need + * to use this property - any object with the `extension` property is a + * valid CodeMirror extension entrypoint. + */ + declare extension: Extension + + private declare field: EditorField + private declare handler: EditorSvelteComponent + + /** + * @param component - The Svelte component the panel will mount with. + * @param opts - {@link EditorSveltePanelOpts} + */ + constructor( + public component: T, + private opts: EditorSveltePanelOpts> = {} + ) { + this.handler = new EditorSvelteComponent(component) + + const create = this.create.bind(this) + this.field = new EditorField({ + default: false, + provide: field => showPanel.from(field, show => (show ? create : null)) + }) + + this.extension = this.field + } + + /** + * Creates the Svelte component and DOM container element and returns the + * CodeMirror panel instance. + */ + private create(view: EditorView): Panel { + const instance = this.handler.create(view, { + unmount: () => this.toggle(view, false) + }) + return { ...instance, top: this.opts.top } + } + + /** + * Toggle, or directly set, the panel's state (whether or not it is mounted). + * + * @param view - The {@link EditorView} that the panel is attached to. + * @param state - Forces the panel to either mount or unmount. + */ + toggle(view: EditorView, state?: boolean) { + if (state === undefined) state = !this.field.get(view) + this.field.set(view, state) + } +} diff --git a/modules/codemirror/tests/adapters.ts b/modules/codemirror/tests/adapters.ts new file mode 100644 index 0000000..8284032 --- /dev/null +++ b/modules/codemirror/tests/adapters.ts @@ -0,0 +1,20 @@ +import { assert, describe, it } from "vitest" +import { LifecycleElement } from "../src/svelte/svelte-lifecycle-element" + +// testing the other adapters might not really be possible, at least in this module +// they ofc require a svelte component to work, and even after you'd need an entire +// CodeMirror editor to test it + +describe("SheafAdapters", () => { + it("disconnect element calls callback", async () => { + const element = new LifecycleElement() + document.documentElement.append(element) + + let disconnected = false + element.addEventListener("disconnected", () => (disconnected = true)) + + document.documentElement.removeChild(element) + + assert.equal(disconnected, true, "Disconnect element did not fire callback") + }) +}) diff --git a/modules/codemirror/tsconfig.json b/modules/codemirror/tsconfig.json new file mode 100644 index 0000000..bcbcf0a --- /dev/null +++ b/modules/codemirror/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "composite": true + }, + "extends": "../../tsconfig.json", + "include": ["src/**/*", "tests/**/*", "../_types/**/*", "cm.ts"] +} diff --git a/modules/comlink/README.md b/modules/comlink/README.md new file mode 100644 index 0000000..55b1d24 --- /dev/null +++ b/modules/comlink/README.md @@ -0,0 +1 @@ +# @wikijump/comlink diff --git a/modules/comlink/package.json b/modules/comlink/package.json new file mode 100644 index 0000000..2e68c64 --- /dev/null +++ b/modules/comlink/package.json @@ -0,0 +1,17 @@ +{ + "name": "@wikijump/comlink", + "license": "agpl-3.0-or-later", + "description": "Comlink wrapper for Wikijump.", + "version": "0.0.0", + "keywords": [ + "wikijump" + ], + "private": true, + "scripts": {}, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@wikijump/util": "workspace:*", + "comlink": "^4.3.1" + } +} diff --git a/modules/comlink/src/index.ts b/modules/comlink/src/index.ts new file mode 100644 index 0000000..589d36f --- /dev/null +++ b/modules/comlink/src/index.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { timedout, TIMED_OUT_SYMBOL } from "@wikijump/util" +import * as Comlink from "comlink" + +const DEFAULT_TIMEOUT = 5000 + +/** + * Releases a remote proxy. This is required to prevent memory leaks. + * + * @param remote - The proxy to release. + */ +export function releaseRemote(remote: Comlink.Remote) { + if (remote[Comlink.releaseProxy]) remote[Comlink.releaseProxy]() +} + +export const transfer = Comlink.transfer + +export type { + Endpoint, + Local, + LocalObject, + ProxyMarked, + ProxyMethods, + ProxyOrClone, + TransferHandler, + UnproxyOrClone +} from "comlink" +export { Comlink } + +export type Remote = Comlink.Remote +export type RemoteObject = Comlink.RemoteObject +export type RemoteWorker = Worker | Remote + +export type BoundWorker = AbstractWorkerBase & Methods> + +export abstract class AbstractWorkerBase { + /** + * Tracks if the worker is still being created. Will be undefined if the + * worker is stopped or fully started. + */ + declare starting?: Promise + + /** The worker instance. */ + declare worker?: Remote + + /** Required function needed for getting a `Worker` or `Comlink.Remote` instance. */ + protected abstract _baseGetWorker(): Promisable | false> + + /** An optional function that will be ran whenever a new worker is created. */ + protected _baseInitalize?(): Promisable + + /** + * Object that allows for setting a default return value function when a + * worker couldn't be started, or if a `_baseBeforeMethod` check failed. + */ + protected _baseDefaults?: { + [P in keyof Methods]?: RemoteObject[P] extends (...args: infer A) => infer R + ? Functionable>, A, this> + : Functionable[P]>>, void, this> + } + + /** + * Number of milliseconds a function can run before an error is thrown + * and the worker is stopped. Defaults to 5000. Set to 0 to disable. + */ + protected _baseMethodTimeout?: number + + /** + * If a worker was provided, its instance will be kept here so that it + * can be forcefully terminated. + */ + private _workerInstance?: Worker + + /** + * Function intended to be used within an `extends` expression. + * Constructs a class with a prototype that binds the given methods of + * the worker proxy. Constructors (classes) count as "methods" too. + * + * This function doesn't have to be used - it's just a convenience. + * However, you'll have to create your own methods that call out to the + * worker. Note that calling worker methods directly will not have any + * protections. + * + * @param methods - The methods to bind. These can't be figured out automatically. + */ + static of(methods: (keyof Methods)[]): AbstractClass> { + // @ts-ignore + const Derived: AbstractClass> = class extends AbstractWorkerBase {} + + for (const method of methods) { + Derived.prototype[method] = async function (this: BoundWorker, ...args: any[]) { + if (this.starting) await this.starting + if (!this.worker) await this.start() + + // check one more time - maybe worker couldn't start + if (!this.worker) return await this._baseTryToGetDefault(method, args) + + if (this._baseMethodTimeout !== 0) { + const result = await timedout( + // @ts-ignore + this.worker![method](...args), + this._baseMethodTimeout ?? DEFAULT_TIMEOUT + ) + + if (result !== TIMED_OUT_SYMBOL) return result + + // worker is timing out, have to stop it + this.stop() + throw new Error(`Method "${method}" timed out!`) + } + } + } + + return Derived + } + + /** Tries to run a default method if the worker couldn't be started. */ + private async _baseTryToGetDefault(method: keyof Methods, args: any[]) { + if (!this._baseDefaults || !this._baseDefaults.hasOwnProperty(method)) { + if (!this.worker) throw new Error(`Worker could not be started!`) + else throw new Error(`Method "${method}" could not be called!`) + } + + const def = this._baseDefaults[method] + + if (typeof def === "function") { + return await def.apply(this, args) + } else { + return def + } + } + + /** True if the worker has been started. */ + get loaded() { + return Boolean(this.worker) + } + + /** + * Starts the worker. + * + * @param force - If true, the worker will be restarted even if it has + * already been started. + */ + async start(force?: boolean) { + if (!force && this.worker) return + + if (this.starting) { + await this.starting + this.starting = undefined + if (!force) return + } + const old = [this.worker, this._workerInstance] as const + + const result = this._baseGetWorker() + if (result instanceof Promise) this.starting = result.then() + const worker: RemoteWorker | false = await result + + if (worker) { + if (worker instanceof Worker) { + this.worker = Comlink.wrap(worker) + this._workerInstance = worker + } else { + this.worker = worker + this._workerInstance = undefined + } + + if (this._baseInitalize) await this._baseInitalize() + + if (old[0]) releaseRemote(old[0]) + if (old[1]) old[1].terminate() + + this.starting = undefined + } + } + + /** Stops the worker. Needed for garbage collection. */ + stop() { + if (this.worker) releaseRemote(this.worker) + if (this._workerInstance) this._workerInstance.terminate() + this.worker = undefined + this._workerInstance = undefined + } +} diff --git a/modules/comlink/tsconfig.json b/modules/comlink/tsconfig.json new file mode 100644 index 0000000..3d3f1dc --- /dev/null +++ b/modules/comlink/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "composite": true + }, + "extends": "../../tsconfig.json", + "include": ["src/**/*", "tests/**/*", "vendor/**/*", "../_types/**/*"] +} diff --git a/modules/dom/README.md b/modules/dom/README.md new file mode 100644 index 0000000..b35106a --- /dev/null +++ b/modules/dom/README.md @@ -0,0 +1 @@ +# @wikijump/dom diff --git a/modules/dom/package.json b/modules/dom/package.json new file mode 100644 index 0000000..c3d63c9 --- /dev/null +++ b/modules/dom/package.json @@ -0,0 +1,17 @@ +{ + "name": "@wikijump/dom", + "license": "agpl-3.0-or-later", + "description": "DOM related utilities for Wikijump.", + "version": "0.0.0", + "keywords": [ + "wikijump" + ], + "private": true, + "scripts": {}, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@popperjs/core": "^2.11.5", + "@wikijump/util": "workspace:*" + } +} diff --git a/modules/dom/src/base-button.ts b/modules/dom/src/base-button.ts new file mode 100644 index 0000000..c58c292 --- /dev/null +++ b/modules/dom/src/base-button.ts @@ -0,0 +1,65 @@ +/** + * Abstract custom element that can serve as a replacement for the + * ` + + + + ` + + const btn = (id: string) => document.getElementById(id) as HTMLButtonElement + + beforeEach(() => { + // @ts-ignore + div = fragment.cloneNode(true).querySelector("#div") + document.body.innerHTML = "" + document.body.appendChild(div) + }) + + describe("getFoci", () => { + it("gets a list", () => { + const foci = lib.getFoci(div) + expect(foci).toHaveLength(2) + expect(foci).not.toContain(btn("minus_tabindex")) + expect(foci).not.toContain(btn("is_disabled")) + }) + + it("gets list with tabindex=-1", () => { + const foci = lib.getFoci(div, true) + expect(foci).toHaveLength(3) + expect(foci).toContain(btn("minus_tabindex")) + }) + }) + + describe("FocusGroup", () => { + it("handles home/end", () => { + const fg = new lib.FocusGroup(div, "vertical") + btn("start").focus() + fireEvent.keyDown(div, { key: "End" }) + expect(document.activeElement).toBe(btn("end")) + fireEvent.keyDown(div, { key: "Home" }) + expect(document.activeElement).toBe(btn("start")) + fg.destroy() + }) + + it("handles arrow keys (vertical)", () => { + const fg = new lib.FocusGroup(div, "vertical") + btn("start").focus() + fireEvent.keyDown(div, { key: "ArrowUp" }) + expect(document.activeElement).toBe(btn("end")) + fireEvent.keyDown(div, { key: "ArrowDown" }) + expect(document.activeElement).toBe(btn("start")) + fireEvent.keyDown(div, { key: "ArrowDown" }) + expect(document.activeElement).toBe(btn("minus_tabindex")) + fg.destroy() + }) + + it("handles arrow keys (horizontal)", () => { + const fg = new lib.FocusGroup(div, "horizontal") + btn("start").focus() + fireEvent.keyDown(div, { key: "ArrowLeft" }) + expect(document.activeElement).toBe(btn("end")) + fireEvent.keyDown(div, { key: "ArrowRight" }) + expect(document.activeElement).toBe(btn("start")) + fireEvent.keyDown(div, { key: "ArrowRight" }) + expect(document.activeElement).toBe(btn("minus_tabindex")) + fg.destroy() + }) + + it("updates", () => { + const fg = new lib.FocusGroup(div, "vertical") + btn("start").focus() + fireEvent.keyDown(div, { key: "ArrowDown" }) + expect(document.activeElement).toBe(btn("minus_tabindex")) + fg.update("horizontal") + fireEvent.keyDown(div, { key: "ArrowLeft" }) + expect(document.activeElement).toBe(btn("start")) + fg.destroy() + }) + }) + + describe("FocusObserver", () => { + it("calls on focus", () => { + let called = false + const fo = new lib.FocusObserver(div, { focus: () => (called = true) }) + btn("start").focus() + expect(called).toBe(true) + fo.destroy() + }) + + it("calls on blur", () => { + let called = false + const fo = new lib.FocusObserver(div, { blur: () => (called = true) }) + btn("start").focus() + btn("start").blur() + expect(called).toBe(true) + fo.destroy() + }) + + it("does not call with refocus", () => { + let count = 0 + const fo = new lib.FocusObserver(div, { focus: () => (count += 1) }) + btn("start").focus() + expect(count).toBe(1) + btn("end").focus() + expect(count).toBe(1) + btn("end").blur() + btn("start").focus() + expect(count).toBe(2) + fo.destroy() + }) + + it("updates", () => { + let called1 = false + let called2 = false + const fo = new lib.FocusObserver(div, { focus: () => (called1 = true) }) + btn("start").focus() + expect(called1).toBe(true) + btn("start").blur() + fo.update({ focus: () => (called2 = true) }) + btn("end").focus() + expect(called2).toBe(true) + fo.destroy() + }) + }) +}) diff --git a/modules/dom/tests/misc.ts b/modules/dom/tests/misc.ts new file mode 100644 index 0000000..9383ad8 --- /dev/null +++ b/modules/dom/tests/misc.ts @@ -0,0 +1,122 @@ +import { fireEvent } from "@testing-library/dom" +import { html, sleep } from "@wikijump/util" +import { beforeEach, describe, expect, it } from "vitest" +import * as lib from "../src/index" + +describe("@wikijump/dom - misc", () => { + describe("HeldObserver", () => { + let button: HTMLButtonElement + + const fragment = html`` + + beforeEach(() => { + // @ts-ignore + button = fragment.cloneNode(true).querySelector("button") + document.body.innerHTML = "" + document.body.appendChild(button) + }) + + it("handles press and release", () => { + let pressed = false + let released = false + const ho = new lib.HeldObserver(button, { + pressed: () => (pressed = true), + released: () => (released = true) + }) + fireEvent.pointerDown(button) + expect(pressed).toBe(true) + expect(released).toBe(false) + fireEvent.pointerUp(button) + expect(pressed).toBe(true) + expect(released).toBe(true) + ho.destroy() + }) + + it("updates", () => { + let called1 = false + let called2 = false + const ho = new lib.HeldObserver(button, { pressed: () => (called1 = true) }) + fireEvent.pointerDown(button) + expect(called1).toBe(true) + fireEvent.pointerUp(button) + ho.update({ pressed: () => (called2 = true) }) + fireEvent.pointerDown(button) + expect(called2).toBe(true) + ho.destroy() + }) + }) + + it("observe", async () => { + const div = document.createElement("div") + document.body.append(div) + let called = false + const ob = lib.observe(div, () => (called = true)) + expect(ob).toBeInstanceOf(MutationObserver) + expect(called).toBe(false) + div.append(document.createElement("div")) + // have to wait for the observer to fire + await sleep(0) + expect(called).toBe(true) + document.body.removeChild(div) + ob.disconnect() + }) + + it("addElement", () => { + const CustomElement = class extends HTMLElement { + static tag = "custom-element" + } + lib.addElement(CustomElement, "CustomElement") + const ce = document.createElement("custom-element") + expect(ce).toBeInstanceOf(CustomElement) + expect("CustomElement" in globalThis).toBe(true) + }) + + it("upgrade", () => { + const fragment = html` +
+ +
+ ` + const div = fragment.querySelector("div")! + const ue = div.querySelector("unloaded-element")! + const NewElement = class extends HTMLElement { + static tag = "unloaded-element" + } + lib.addElement(NewElement) + expect(ue).not.toBeInstanceOf(NewElement) + lib.upgrade(div, NewElement) + expect(ue).toBeInstanceOf(NewElement) + }) + + it("UserAgent", () => { + expect(lib.UserAgent.isMobile).toBe(false) + expect(typeof lib.UserAgent.mouseX).toBe("number") + expect(typeof lib.UserAgent.mouseY).toBe("number") + expect(typeof lib.UserAgent.scroll).toBe("number") + }) + + it("inputsValid", () => { + const fragment = html` +
+ + + +
+ ` + const div = fragment.querySelector("div")! + const inputs = Array.from(div.querySelectorAll("input")) + expect(lib.inputsValid(...inputs)).toBe(true) + inputs[0].required = true + inputs[0].value = "" + expect(lib.inputsValid(...inputs)).toBe(false) + inputs[0].required = false + inputs[0].value = "foo" + inputs[0].disabled = true + expect(lib.inputsValid(...inputs)).toBe(false) + inputs[0].disabled = false + inputs[0].readOnly = true + expect(lib.inputsValid(...inputs)).toBe(false) + inputs[0].readOnly = false + expect(lib.inputsValid(...inputs)).toBe(true) + }) +}) diff --git a/modules/dom/tsconfig.json b/modules/dom/tsconfig.json new file mode 100644 index 0000000..d3537a6 --- /dev/null +++ b/modules/dom/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "composite": true + }, + "extends": "../../tsconfig.json", + "include": ["src/**/*", "tests/**/*", "../_types/**/*"] +} diff --git a/modules/prism/README.md b/modules/prism/README.md new file mode 100644 index 0000000..207fe7b --- /dev/null +++ b/modules/prism/README.md @@ -0,0 +1,113 @@ +# @wikijump/prism + +This module wraps around the Prism syntax highlighting library. + +> ### IMPORTANT +> The Prism library is vendored into this package. The file Prism resides in is mostly unmodified, except in two ways: +> 1. The `manual` property is hardcoded to `true` +> 2. The `disableWorkerMessageHandler` is hardcoded to `true` +> +> If the `prism.js` file is updated, e.g. with new languages, you must make the same edits to the file. +> +> These values will be found near the top of whatever Prism file you are editing. + +The following languages have highlighting support: +* `css` +* `clike` +* `javascript` +* `abnf` +* `actionscript` +* `apl` +* `arduino` +* `asciidoc` +* `aspnet` +* `autohotkey` +* `bash` +* `basic` +* `batch` +* `bnf` +* `brainfuck` +* `brightscript` +* `c` +* `csharp` +* `cpp` +* `clojure` +* `cobol` +* `coffeescript` +* `crystal` +* `csv` +* `d` +* `dart` +* `diff` +* `docker` +* `ebnf` +* `editorconfig` +* `elixir` +* `elm` +* `erlang` +* `fsharp` +* `flow` +* `fortran` +* `git` +* `glsl` +* `go` +* `graphql` +* `haskell` +* `hcl` +* `hlsl` +* `http` +* `hpkp` +* `hsts` +* `ignore` +* `ini` +* `java` +* `json` +* `json5` +* `jsonp` +* `julia` +* `kotlin` +* `latex` +* `less` +* `lisp` +* `log` +* `lua` +* `makefile` +* `markdown` +* `matlab` +* `nasm` +* `nginx` +* `nim` +* `objectivec` +* `ocaml` +* `opencl` +* `pascal` +* `perl` +* `php` +* `plsql` +* `powershell` +* `purescript` +* `python` +* `qml` +* `r` +* `jsx` +* `tsx` +* `regex` +* `rest` +* `ruby` +* `rust` +* `sass` +* `scss` +* `scala` +* `scheme` +* `smalltalk` +* `smarty` +* `sql` +* `stylus` +* `swift` +* `toml` +* `typescript` +* `v` +* `vim` +* `wasm` +* `yaml` +* `zig` diff --git a/modules/prism/package.json b/modules/prism/package.json new file mode 100644 index 0000000..41d87bf --- /dev/null +++ b/modules/prism/package.json @@ -0,0 +1,19 @@ +{ + "name": "@wikijump/prism", + "license": "agpl-3.0-or-later", + "description": "Wrapper around the Prism library for Wikijump.", + "version": "0.0.0", + "keywords": [ + "wikijump" + ], + "private": true, + "scripts": {}, + "type": "module", + "main": "src/index.ts", + "devDependencies": { + "@types/prismjs": "^1.26.0" + }, + "dependencies": { + "@wikijump/comlink": "workspace:*" + } +} diff --git a/modules/prism/src/ftml.ts b/modules/prism/src/ftml.ts new file mode 100644 index 0000000..22948db --- /dev/null +++ b/modules/prism/src/ftml.ts @@ -0,0 +1,159 @@ +import type { Prism as PrismType } from "./index" + +/** Adds FTML syntax highlighting to a Prism instance. */ +export function prismFTML(Prism: PrismType) { + function generateEmbedded(embed: string, start: string, end = start) { + const pattern = new RegExp( + `(\\[\\[\\s*${start}[^]*?\\]\\])([^]*?(?=\\[\\[\\/\\s*${end}\\s*\\]\\]))`, + "im" + ) + return { + pattern, + lookbehind: true, + // use a getter so that the language doesn't have to exist right away + // this is so that we can do recursive highlighting (see below) + get inside() { + return Prism.languages[embed] + } + } + } + + const codePatterns: Record> = {} + // languages that we'll add [[code]] embedded highlighting for + const highlightLanguages = [ + "ftml", + "wikidot", + "wikijump", + "wikitext", + ...Object.keys(Prism.languages) + ] + // make a embedded highlighting rule for every language from the above list + for (const language of highlightLanguages) { + codePatterns[`code-${language}`] = generateEmbedded( + language, + `code[^]*?type\\s*=\\s*"\\s*${language}\\s*"`, + "code" + ) + } + + Prism.languages.ftml = { + "comment": /\[!--[^]*?--\]/im, + + "escape-nl": { + pattern: / (_|\\)$/im, + alias: "escaped" + }, + + "escape-at": { + pattern: /@@[^]+?@@/i, + alias: "escaped" + }, + + "escape-bracket": { + pattern: /@<[^]+?>@/im, + alias: "escaped" + }, + + "link-triple": { + pattern: /(\[{3}(?!\[))([^\n\[\]]+)((?!\]{4})\]{3})/, + inside: { + "punctuation": /\[\[\[|\]\]\]|\|/, + "url": /[^\[\]\|]+/ + } + }, + + "embedded-css": generateEmbedded("css", "css"), + "embedded-css-module": generateEmbedded("css", "module\\s*css", "module"), + "embedded-html": generateEmbedded("html", "html"), + "embedded-math": generateEmbedded("tex", "math"), + + ...codePatterns, + "code": generateEmbedded("plaintext", "code"), + + "block": { + // this horrifying pattern is actually what the CM parser uses (mostly, anyways) + // however unlike in Tarnation we can't use regexp variables easily so... + // just accept this as black magic and move on - if it needs to be edited, + // use the Tarnation parser as reference and don't try to hand make this + pattern: + /((\[{2}(?!\[)\s*(?!\/))|(\[{2}\/\s*))(((?:[*=><](?![*=><])|f>|f<)(?![^\S\r\n]|(\s*(?!\]{3})\]{2})))?)([^\\#*\s\]]+?)(_?(?=[^\S\r\n]|\s*(?!\]{3})\]{2}|$))([^]*?)(\s*(?!\]{3})\]{2})/im, + inside: { + "block-name": { + pattern: /(^\[\[\/?)((?:[*=><](?![*=><])|f>|f<)*)([^\s\]]+)/i, + lookbehind: true, + inside: { + "keyword": /(^([*=><]|f>|f<))|_$/i, + "tag": /[^\s*=><_]+/i + } + }, + "argument": { + pattern: /(\S+?)(\s*=\s*)/i, + inside: { + "attr-name": /[^\s=]/i, + "operator": /=/i + } + }, + "string": /"[^"]*"/i, + "punctuation": /\[\/|[\[\]]/i, + "block-label": { + pattern: /([^\s\]=](?![="]))+/i, + alias: "string" + } + } + }, + + "table-mark": { + pattern: /(\|{2,})([~=]?)/i, + alias: "punctuation" + }, + + "blockquote": { + pattern: /^\s*>(?:[\t ]*>)*/im, + alias: "keyword" + }, + + "list-hash": { + pattern: /^\s*#(?!#)(?:[\t ]*#(?!#))*/im, + alias: "keyword" + }, + + "list-star": { + pattern: /^\s*\*(?!\*)(?:[\t ]*\*(?!\*))*/im, + alias: "keyword" + }, + + "hr": { + pattern: /(^(?:\s*|>*|\|\|[~=]?))(?:-{3,}|={3,})\s*$/im, + lookbehind: true, + alias: "keyword" + }, + + "heading": { + pattern: /(^(?:\s*|>*|\|\|[~=]?))(?:\++\*?)\s+(?!$)/im, + lookbehind: true, + alias: "keyword" + }, + + "colored-text": { + pattern: /##\w+\|/i, + inside: { + "punctuation": /##|\|/i, + "constant": /\w+/i + } + }, + + "colored-text-end": { + pattern: /##/i, + alias: "punctuation" + }, + + "formatting": { + pattern: /\*\*|\/\/|__|--|,,|\^\^|\{\{|\}\}/i, + alias: "operator" + } + } + + Prism.languages.wikidot = Prism.languages.ftml + Prism.languages.wikijump = Prism.languages.ftml + Prism.languages.wikitext = Prism.languages.ftml +} diff --git a/modules/prism/src/index.ts b/modules/prism/src/index.ts new file mode 100644 index 0000000..c0feaa0 --- /dev/null +++ b/modules/prism/src/index.ts @@ -0,0 +1,17 @@ +import { AbstractWorkerBase } from "@wikijump/comlink" +import type { PrismModule } from "./worker" + +export type Prism = typeof globalThis.Prism + +export class PrismWorker extends AbstractWorkerBase.of([ + "disableWorkerMessageHandler", + "getLanguages", + "highlight", + "manual" +]) { + protected _baseGetWorker() { + return new Worker(new URL("./worker", import.meta.url), { type: "module" }) + } +} + +export default new PrismWorker() diff --git a/modules/prism/src/worker.ts b/modules/prism/src/worker.ts new file mode 100644 index 0000000..4e2c128 --- /dev/null +++ b/modules/prism/src/worker.ts @@ -0,0 +1,71 @@ +import { Comlink } from "@wikijump/comlink" +import type PrismType from "prismjs" +import "../vendor/prism" +import { prismBase } from "../vendor/prism-langs" +import { prismSvelte } from "../vendor/prism-svelte" +import { prismFTML } from "./ftml" + +/** Reference to the Prism syntax highlighter. */ +export const Prism: typeof PrismType = globalThis.Prism + +// add languages +prismBase(Prism) +prismSvelte(Prism) +prismFTML(Prism) + +// set prism class prefix +// https://prismjs.com/plugins/custom-class/ +Prism.plugins.customClass.prefix("wj-code-") + +// yoink Prism's encode function so that we can escape strings identically +const encode: (src: string) => string = Prism.util.encode as any + +const RAW_LANGS = ["raw", "text", "none", ""] + +const module = { + /** Returns the list of languages (and their aliases) supported by Prism. */ + getLanguages() { + return Object.keys(Prism.languages).filter( + lang => typeof Prism.languages[lang] !== "function" + ) + }, + /** + * Highlights a string of code and returns HTML, given a specified + * language. If the language specified isn't known by Prism, the string + * of code will be escaped and returned with no syntax highlighting. + * + * If the given language is `raw`, `text`, `none`, or an empty string, + * the string of code will be escaped and returned as is. + * + * @param code - The string to be highlighted. + * @param lang - The language to highlight the code with. + */ + highlight(code: string, lang: string) { + try { + if (lang && !RAW_LANGS.includes(lang) && lang in Prism.languages) { + const grammar = Prism.languages[lang] + const html = Prism.highlight(code, grammar, lang) + return html + } + } catch {} + + // fallback to just returning the escaped code + return encode(code) + }, + + /** Sets or gets the status of Prism's `manual` property. */ + manual(set?: boolean) { + if (typeof set !== "undefined") Prism.manual = set + return Prism.manual + }, + + /** Sets or gets the status of Prism's `disableWorkerMessageHandler` property. */ + disableWorkerMessageHandler(set?: boolean) { + if (typeof set !== "undefined") Prism.disableWorkerMessageHandler = set + return Prism.disableWorkerMessageHandler ?? false + } +} + +export type PrismModule = typeof module + +Comlink.expose(module) diff --git a/modules/prism/tests/prism.skipped b/modules/prism/tests/prism.skipped new file mode 100644 index 0000000..e03d98d --- /dev/null +++ b/modules/prism/tests/prism.skipped @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest" +import * as lib from "../src/index" + +// This file is being skipped for now due to issues with Vitest and web-workers. +// Simply using `describe.skip(...)` won't work because Vitest will still try to transform +// this file, and will error when doing so. +// It tries to parse a `new Worker(new URL(url, import. meta.url))` call, which it can't. + +describe("@wikijump/prism", () => { + const worker = lib.default + + it("needs manual to be true", async () => { + expect(await worker.manual()).to.be.true + }) + + it("needs disableWorkerMessageHandler to be true", async () => { + expect(await worker.disableWorkerMessageHandler()).to.be.true + }) + + it("should highlight", async () => { + const html = await worker.highlight('console.log("foo")', "javascript") + const snapshot = `console.log("foo")` + expect(html).to.equal(snapshot) + }) + + it("should highlight with raw text", async () => { + const html = await worker.highlight('console.log("foo")', "raw") + expect(html).to.equal('console.log("foo")') + }) + + it("should highlight even with a bad language", async () => { + const html = await worker.highlight( + 'console.log("foo")', + "bad-language-that-doesn't-exist" + ) + expect(html).to.equal('console.log("foo")') + }) +}) diff --git a/modules/prism/tsconfig.json b/modules/prism/tsconfig.json new file mode 100644 index 0000000..26d9994 --- /dev/null +++ b/modules/prism/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "composite": true + }, + "extends": "../../tsconfig.json", + "include": ["src/**/*", "tests/**/*", "../_types/**/*", "vendor/**/*"] +} diff --git a/modules/prism/vendor/prism-langs.js b/modules/prism/vendor/prism-langs.js new file mode 100644 index 0000000..e045df8 --- /dev/null +++ b/modules/prism/vendor/prism-langs.js @@ -0,0 +1,7796 @@ +/* PrismJS 1.23.0 +https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+abnf+actionscript+apl+arduino+asciidoc+aspnet+autohotkey+bash+basic+batch+bnf+brainfuck+brightscript+c+csharp+cpp+clojure+cobol+coffeescript+crystal+css-extras+csv+d+dart+diff+docker+ebnf+editorconfig+elixir+elm+erlang+fsharp+flow+fortran+git+glsl+go+graphql+haskell+hcl+hlsl+http+hpkp+hsts+ignore+ini+java+javadoc+javadoclike+jsdoc+js-extras+json+json5+jsonp+jsstacktrace+julia+kotlin+latex+less+lisp+log+lua+makefile+markdown+markup-templating+matlab+nasm+nginx+nim+objectivec+ocaml+opencl+pascal+perl+php+phpdoc+php-extras+plsql+powershell+purescript+python+qml+r+jsx+tsx+regex+rest+ruby+rust+sass+scss+scala+scheme+smalltalk+smarty+sql+stylus+swift+toml+typescript+v+vim+wasm+yaml+zig&plugins=autolinker+custom-class+highlight-keywords */ + +export function prismBase(Prism) { + Prism.languages.markup = { + 'comment': //, + 'prolog': /<\?[\s\S]+?\?>/, + 'doctype': { + // https://www.w3.org/TR/xml/#NT-doctypedecl + pattern: /"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i, + greedy: true, + inside: { + 'internal-subset': { + pattern: /(\[)[\s\S]+(?=\]>$)/, + lookbehind: true, + greedy: true, + inside: null // see below + }, + 'string': { + pattern: /"[^"]*"|'[^']*'/, + greedy: true + }, + 'punctuation': /^$|[[\]]/, + 'doctype-tag': /^DOCTYPE/, + 'name': /[^\s<>'"]+/ + } + }, + 'cdata': //i, + 'tag': { + pattern: /<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/, + greedy: true, + inside: { + 'tag': { + pattern: /^<\/?[^\s>\/]+/, + inside: { + 'punctuation': /^<\/?/, + 'namespace': /^[^\s>\/:]+:/ + } + }, + 'special-attr': [], + 'attr-value': { + pattern: /=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/, + inside: { + 'punctuation': [ + { + pattern: /^=/, + alias: 'attr-equals' + }, + /"|'/ + ] + } + }, + 'punctuation': /\/?>/, + 'attr-name': { + pattern: /[^\s>\/]+/, + inside: { + 'namespace': /^[^\s>\/:]+:/ + } + } + + } + }, + 'entity': [ + { + pattern: /&[\da-z]{1,8};/i, + alias: 'named-entity' + }, + /&#x?[\da-f]{1,8};/i + ] + }; + + Prism.languages.markup['tag'].inside['attr-value'].inside['entity'] = + Prism.languages.markup['entity']; + Prism.languages.markup['doctype'].inside['internal-subset'].inside = Prism.languages.markup; + + // Plugin to make entity title show the real entity, idea by Roman Komarov + Prism.hooks.add('wrap', function (env) { + + if (env.type === 'entity') { + env.attributes['title'] = env.content.replace(/&/, '&'); + } + }); + + Object.defineProperty(Prism.languages.markup.tag, 'addInlined', { + /** + * Adds an inlined language to markup. + * + * An example of an inlined language is CSS with ` diff --git a/modules/sheaf/src/components/MorphingWikitext.svelte b/modules/sheaf/src/components/MorphingWikitext.svelte new file mode 100644 index 0000000..0e88c30 --- /dev/null +++ b/modules/sheaf/src/components/MorphingWikitext.svelte @@ -0,0 +1,105 @@ + + + + + {#each stylesheets as style (style)} + + {/each} + + +
diff --git a/modules/sheaf/src/components/PaneEditor.svelte b/modules/sheaf/src/components/PaneEditor.svelte new file mode 100644 index 0000000..f911159 --- /dev/null +++ b/modules/sheaf/src/components/PaneEditor.svelte @@ -0,0 +1,30 @@ + + + +
+
+
+ + diff --git a/modules/sheaf/src/components/PaneEditorTopbar.svelte b/modules/sheaf/src/components/PaneEditorTopbar.svelte new file mode 100644 index 0000000..19393d0 --- /dev/null +++ b/modules/sheaf/src/components/PaneEditorTopbar.svelte @@ -0,0 +1,183 @@ + + + +
+ + +
+ + +
+ + + + + + + + + +
{$t("#-stats.chars")}{number(chars, { useGrouping: false })}
{$t("#-stats.bytes")} + {unit(bytes, "kilobyte", { useGrouping: false, unitDisplay: "narrow" })} +
+ + + + + + + + + +
{$t("#-stats.words")}{number(words, { useGrouping: false })}
{$t("#-stats.lines")}{number(lines, { useGrouping: false })}
+
+ +
+ +
+ +
+ + + {#if !$small} +
+ {#if $settings.preview.enabled} +
+ {/if} +
+ + diff --git a/modules/sheaf/src/components/PanePreview.svelte b/modules/sheaf/src/components/PanePreview.svelte new file mode 100644 index 0000000..7020f01 --- /dev/null +++ b/modules/sheaf/src/components/PanePreview.svelte @@ -0,0 +1,142 @@ + + + +
+ + + {$t("#-preview.result")} + + {#if showRendering} +
`opacity: ${t}` }} + > + +
+ {/if} + + {#if debug} +
+ +
+ {$t("#-preview.compile")} + {unit(timeCompile, "millisecond", { unitDisplay: "narrow" })} +
+
+ {$t("#-preview.patch")} + {unit(timePatch, "millisecond", { unitDisplay: "narrow" })} +
+
+ {$t("#-preview.total")} + {unit(timeTotal, "millisecond", { unitDisplay: "narrow" })} +
+
+
+ {/if} + +
+ +
+
+ + + {$t("#-preview.html")} + + + + + {$t("#-preview.css")} + + + + {#if debug} + + {$t("#-preview.ast")} + + + + + {$t("#-preview.tokens")} + + + + + {$t("#-preview.editor-ast")} + + + {/if} +
+
+ + diff --git a/modules/sheaf/src/components/SettingsMenu.svelte b/modules/sheaf/src/components/SettingsMenu.svelte new file mode 100644 index 0000000..fb5289e --- /dev/null +++ b/modules/sheaf/src/components/SettingsMenu.svelte @@ -0,0 +1,89 @@ + + + +
+ FTML Performance: + {#each ftmlPerfs as perf} + {unit(perf, "millisecond", { unitDisplay: "narrow" })} + {/each} +
+ + diff --git a/modules/sheaf/src/context.ts b/modules/sheaf/src/context.ts new file mode 100644 index 0000000..96909ee --- /dev/null +++ b/modules/sheaf/src/context.ts @@ -0,0 +1,36 @@ +import type { Readable, Writable } from "svelte/store" +import type { SheafCore } from "./core" +import type { SheafBindings } from "./extensions/bindings" + +export interface SheafSettings { + debug: boolean + editor: { + darkmode: boolean + spellcheck: boolean + } + preview: { + enabled: boolean + darkmode: boolean + } +} + +export interface SheafContext { + editor: SheafCore + bindings: SheafBindings + settings: Writable + small: Readable +} + +export function getDefaultSheafSettings(): SheafSettings { + return { + debug: false, + editor: { + darkmode: true, + spellcheck: true + }, + preview: { + enabled: true, + darkmode: false + } + } +} diff --git a/modules/sheaf/src/core.ts b/modules/sheaf/src/core.ts new file mode 100644 index 0000000..d86b04b --- /dev/null +++ b/modules/sheaf/src/core.ts @@ -0,0 +1,91 @@ +import { autocompletion, closeBrackets } from "@codemirror/autocomplete" +import { history } from "@codemirror/commands" +import { bracketMatching, indentOnInput, syntaxHighlighting } from "@codemirror/language" +import { highlightSelectionMatches } from "@codemirror/search" +import { EditorState, type Extension } from "@codemirror/state" +import { + drawSelection, + EditorView, + highlightActiveLine, + highlightSpecialChars, + rectangularSelection, + scrollPastEnd, + tooltips, + ViewPlugin, + type ViewUpdate +} from "@codemirror/view" +import { Spellcheck } from "@wikijump/cm-espells" +import { defaultLanguages, Gutters, IndentHack } from "@wikijump/codemirror" +import { writable, type Writable } from "svelte/store" +import { createSheafBinding, type SheafBindings } from "./extensions/bindings" +import { getSheafKeymap } from "./extensions/keymap" +import { confinement } from "./extensions/theme" +import { SheafState } from "./state" + +export class SheafCore { + declare state: SheafState + private declare store: Writable + declare subscribe: Writable["subscribe"] + declare set: Writable["set"] + + constructor(doc: string, bindings: SheafBindings = {}, extensions: Extension[] = []) { + const updateHandler = ViewPlugin.define(() => ({ + update: viewUpdate => this.update(viewUpdate) + })) + + const view = new EditorView({ + state: EditorState.create({ + doc, + extensions: [ + highlightSpecialChars(), + history(), + drawSelection(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + closeBrackets(), + highlightSelectionMatches(), + autocompletion(), + rectangularSelection(), + highlightActiveLine(), + EditorView.lineWrapping, + scrollPastEnd(), + tooltips({ position: "absolute" }), + getSheafKeymap(), + IndentHack, + Gutters, + Spellcheck, + defaultLanguages, + syntaxHighlighting(confinement), + createSheafBinding(this, bindings), + extensions, + updateHandler + ] + }) + }) + + this.state = new SheafState({ self: this, view, bindings }) + this.store = writable(this.state) + this.subscribe = this.store.subscribe + this.set = this.store.set + } + + private update(update: ViewUpdate) { + // if (!update.docChanged && !update.selectionSet) return + if (!update.docChanged) return + this.state = this.state.extend() + this.store.set(this.state) + } + + mount(element: Element) { + element.appendChild(this.state.view.dom) + } + + /** + * Destroys the editor. Usage of the editor object after destruction is + * obviously not recommended. + */ + destroy() { + this.state.view.destroy() + } +} diff --git a/modules/sheaf/src/extensions/bindings.ts b/modules/sheaf/src/extensions/bindings.ts new file mode 100644 index 0000000..c1152e4 --- /dev/null +++ b/modules/sheaf/src/extensions/bindings.ts @@ -0,0 +1,38 @@ +import type { Extension } from "@codemirror/state" +import { keymap, ViewPlugin } from "@codemirror/view" +import { debounce } from "@wikijump/util" +import type { SheafCore } from "../core" + +export interface SheafBindings { + /** Callback fired when the user "saves", e.g. hitting `CTRL + S`. */ + save?: (core: SheafCore) => void + + /** + * Callback fired when the document state changes. _This is debounced._ + * It won't be called immediately after a change. + */ + update?: (core: SheafCore) => void +} + +export function createSheafBinding(core: SheafCore, bindings: SheafBindings) { + const extensions: Extension[] = [] + + if (bindings.save) { + extensions.push( + keymap.of([ + { key: "Mod-S", run: () => (bindings.save!(core), true), preventDefault: true } + ]) + ) + } + + if (bindings.update) { + const callback = debounce(bindings.update, 50) + extensions.push( + ViewPlugin.define(() => ({ + update: () => callback(core) + })) + ) + } + + return extensions +} diff --git a/modules/sheaf/src/extensions/keymap.ts b/modules/sheaf/src/extensions/keymap.ts new file mode 100644 index 0000000..e8fc410 --- /dev/null +++ b/modules/sheaf/src/extensions/keymap.ts @@ -0,0 +1,34 @@ +import { closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete" +import { + copyLineDown, + defaultKeymap, + historyKeymap, + indentWithTab, + redo +} from "@codemirror/commands" +import { foldKeymap } from "@codemirror/language" +import { nextDiagnostic, openLintPanel } from "@codemirror/lint" +import { searchKeymap } from "@codemirror/search" +import { keymap } from "@codemirror/view" + +/** Additional key bindings for the editor. */ +const KEY_MAP = [ + { key: "Mod-l", run: openLintPanel, preventDefault: true }, + { key: "F8", run: nextDiagnostic, preventDefault: true }, + { key: "Mod-Shift-z", run: redo, preventDefault: true }, + { key: "Mod-d", run: copyLineDown, preventDefault: true } +] + +/** Returns an extension for Sheaf's full keybinding set. */ +export function getSheafKeymap() { + return keymap.of([ + ...defaultKeymap, + ...closeBracketsKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...completionKeymap, + ...KEY_MAP, + indentWithTab + ]) +} diff --git a/modules/sheaf/src/extensions/theme.ts b/modules/sheaf/src/extensions/theme.ts new file mode 100644 index 0000000..698642a --- /dev/null +++ b/modules/sheaf/src/extensions/theme.ts @@ -0,0 +1,117 @@ +import { HighlightStyle } from "@codemirror/language" +import { tags as t } from "@lezer/highlight" + +// prettier-ignore +const + background = "var(--colcode-background)", + hover = "var(--colcode-hover)" , + border = "var(--colcode-border)" , + accent = "var(--colcode-accent)" , + selection = "var(--colcode-selection)" , + text = "var(--colcode-content)" , + comment = "var(--colcode-comment)" , + doc = "var(--colcode-commentdoc)", + punct = "var(--colcode-punct)" , + operator = "var(--colcode-operator)" , + storage = "var(--colcode-storage)" , + keyword = "var(--colcode-keyword)" , + logical = "var(--colcode-logical)" , + string = "var(--colcode-string)" , + entity = "var(--colcode-entity)" , + type = "var(--colcode-type)" , + ident = "var(--colcode-ident)" , + func = "var(--colcode-function)" , + constant = "var(--colcode-constant)" , + property = "var(--colcode-property)" , + tag = "var(--colcode-tag)" , + classes = "var(--colcode-class)" , + attr = "var(--colcode-attribute)" , + markup = "var(--colcode-markup)" , + link = "var(--colcode-link)" , + invalid = "var(--colcode-invalid)" , + inserted = "var(--colcode-inserted)" , + changed = "var(--colcode-changed)" , + important = "var(--colcode-important)" , + highlight = "var(--colcode-highlight)" , + note = "var(--colcode-note)" , + special = "var(--colcode-special)" + +// prettier-ignore +export const confinement = HighlightStyle.define([ + // Keywords, Operators, Language Features + { tag: t.keyword, color: keyword }, + { tag: t.operator, color: operator }, + { tag: t.labelName, color: string }, + { tag: t.self, color: special }, + { tag: t.atom, color: type }, + { tag: [t.controlOperator, t.logicOperator, t.compareOperator], color: logical }, + { tag: [t.modifier, t.definitionKeyword], color: storage }, + + // Names + { tag: t.name, color: ident }, + { tag: t.propertyName, color: property }, + { tag: t.className, color: classes }, + { tag: t.namespace, color: entity }, + + // Constants, Literals + { tag: [t.constant(t.name), t.constant(t.variableName)], color: constant }, + { tag: [t.string, t.special(t.string),t.regexp], color: string }, + { tag: [t.literal, t.integer, t.float, t.bool, t.unit, t.null], color: constant }, + + // Types + { tag: [ + t.typeName, + t.annotation, + t.special(t.name), + t.standard(t.name), + t.standard(t.variableName) + ], + color: type + }, + + // Functions + { tag: [ + t.function(t.name), + t.function(t.variableName), + t.function(t.propertyName), + t.definition(t.function(t.variableName)), + t.definition(t.function(t.propertyName)), + t.macroName + ], + color: func + }, + + // Changes + { tag: t.inserted, color: inserted }, + { tag: t.changed, color: changed }, + { tag: [t.deleted, t.invalid], color: invalid }, + + // Punctuation, Comments + { tag: t.punctuation, color: punct }, + { tag: t.processingInstruction, color: markup }, + { tag: t.escape, color: type }, + { tag: [t.meta, t.comment], color: comment }, + { tag: [t.docComment, t.docString], color: doc }, + + // Markup + { tag: t.tagName, color: tag }, + { tag: t.special(t.tagName), color: tag }, + { tag: t.attributeName, color: attr }, + { tag: t.attributeValue, color: string }, + { tag: t.link, color: link }, + { tag: t.monospace, color: string }, + { tag: t.url, color: link, textDecoration: "underline" }, + { tag: t.heading, color: tag, fontWeight: "bold" }, + { tag: t.special(t.inserted), color: "black", background: important }, + { tag: t.strong, fontWeight: "bold" }, + { tag: t.emphasis, fontStyle: "italic" }, + { tag: t.strikethrough, textDecoration: "line-through" }, + { tag: t.special(t.emphasis), textDecoration: "underline" }, + { tag: t.contentSeparator, + fontWeight: "bold", + color: tag, + display: "inline-block", + width: "calc(100% - 1rem)", + boxShadow: `inset 0 0.125rem 0 ${border}` + }, +]) diff --git a/modules/sheaf/src/index.ts b/modules/sheaf/src/index.ts new file mode 100644 index 0000000..5530e66 --- /dev/null +++ b/modules/sheaf/src/index.ts @@ -0,0 +1,2 @@ +export { default as CodeDisplay } from "./components/CodeDisplay.svelte" +export { default as Sheaf } from "./components/Sheaf.svelte" diff --git a/modules/sheaf/src/state.ts b/modules/sheaf/src/state.ts new file mode 100644 index 0000000..c508590 --- /dev/null +++ b/modules/sheaf/src/state.ts @@ -0,0 +1,181 @@ +import { syntaxTree } from "@codemirror/language" +import type { EditorState, Text } from "@codemirror/state" +import type { EditorView } from "@codemirror/view" +import type { Tree } from "@lezer/common" +import { + getActiveLines, + Gutters, + printTree, + textBuffer, + textValue +} from "@wikijump/codemirror" +import FTML, { + type SyntaxTree, + type Token, + type Warning +} from "@wikijump/ftml-wasm-worker" +import { Memoize } from "typescript-memoize" +import type { SheafCore } from "./core" +import type { SheafBindings } from "./extensions/bindings" + +export interface SheafStateConstructorOpts { + self: SheafCore + view: EditorView + bindings: SheafBindings +} + +export class SheafState { + /** The {@link SheafCore} instance this state is describing. */ + declare readonly self: SheafCore + + /** The parent DOM element the editor is attached to. */ + declare readonly parent: Element + + /** The `EditorView` of the CM6 instance. */ + declare readonly view: EditorView + + /** The bindings Sheaf is using. */ + declare readonly bindings: SheafBindings + + /** The current `EditorState` of the CM6 instance. */ + declare readonly state: EditorState + + /** The `Text` object from `state.doc`. */ + declare readonly doc: Text + + /** The currently active line numbers. */ + declare readonly activeLines: Set + + /** @param opts - The starting state configuration to use. */ + constructor(opts: SheafStateConstructorOpts) { + this.self = opts.self + this.view = opts.view + this.bindings = opts.bindings + this.state = opts.view.state + this.doc = opts.view.state.doc + this.activeLines = getActiveLines(opts.view.state) + } + + /** Retrieves the value of the editor. */ + @Memoize() + async value() { + return await textValue(this.doc) + } + + /** Retrieves the value of the editor as an `Uint8Array`. */ + @Memoize() + async buffer() { + return await textBuffer(this.doc) + } + + /** Renders the editor with FTML. */ + @Memoize() + async render() { + return await FTML.renderHTML(await this.value(), undefined, "draft") + } + + /** Renders the document to HTML. */ + @Memoize() + async html(format = false) { + const { html } = await this.render() + return format ? await FTML.formatHTML(html) : html + } + + /** Renders the document and returns its stylesheets. */ + @Memoize() + async styles() { + const { styles } = await this.render() + return styles + } + + /** Renders the document and returns its combined stylesheet. */ + @Memoize() + async style() { + const { styles } = await this.render() + return styles + .map((style, idx) => `/* stylesheet ${idx + 1} */\n\n${style}\n\n`) + .join("\n") + } + + /** Gets the document's resultant FTML AST and warnings. */ + @Memoize() + async parse() { + return await FTML.parse(await this.value()) + } + + /** Gets the document's resultant FTML AST. */ + @Memoize() + async ast(): Promise { + const { ast } = await this.parse() + return ast + } + + /** Gets a pretty-printed JSON version of the FTML AST. */ + @Memoize() + async prettyAST() { + const ast = await this.ast() + return JSON.stringify(ast, undefined, 2) + } + + /** Gets the document's FTML emitted warnings. */ + @Memoize() + async warnings(): Promise { + const { warnings } = await this.parse() + return warnings + } + + /** Tokenizes the document with FTML. */ + @Memoize() + async tokenize(): Promise { + return await FTML.tokenize(await this.value()) + } + + /** Tokenizes the document and returns the result as a pretty-printed string. */ + @Memoize() + async inspectTokens() { + return await FTML.inspectTokens(await this.value()) + } + + /** Gets the word count via inspection of the FTML AST. */ + @Memoize() + async wordCount() { + return await FTML.wordCount(await this.value()) + } + + /** Creates a pretty printed version of the editor's syntax tree. */ + @Memoize() + async prettyEditorAST() { + return printTree(this.tree, await this.value()) + } + + /** The current _editor_ syntax tree. */ + get tree(): Tree { + return syntaxTree(this.state) + } + + /** True if the gutters are being shown. Can be set. */ + get gutters() { + return Gutters.get(this.state) + } + + /** True if the gutters are being shown. Can be set. */ + set gutters(state: boolean) { + Gutters.set(this.view, state) + } + + /** + * Extends this state and creates a new one. + * + * @param opts - Options to pass to the new state. + */ + extend(opts?: Partial) { + // TODO: reimplement memoization manually + // so that if the documentdoesn't change, we copy over the rendered result + return new SheafState({ + self: this.self, + view: this.view, + bindings: this.bindings, + ...opts + }) + } +} diff --git a/modules/sheaf/tsconfig.json b/modules/sheaf/tsconfig.json new file mode 100644 index 0000000..d3537a6 --- /dev/null +++ b/modules/sheaf/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "composite": true + }, + "extends": "../../tsconfig.json", + "include": ["src/**/*", "tests/**/*", "../_types/**/*"] +} diff --git a/modules/util/README.md b/modules/util/README.md new file mode 100644 index 0000000..eb30b15 --- /dev/null +++ b/modules/util/README.md @@ -0,0 +1,15 @@ +# @wikijump/util + +Exports a plethora of utility functions widely used by Wikijump's packages. + +Part of the [Wikijump project.](https://github.com/scpwiki/wikijump) + +## Installation + +``` +$ npm install @wikijump/util +``` + +## License + +Released under the AGPL-3.0 license. See [LICENSE](https://github.com/scpwiki/wikijump/blob/develop/LICENSE.md). diff --git a/modules/util/package.json b/modules/util/package.json new file mode 100644 index 0000000..fc3c0a1 --- /dev/null +++ b/modules/util/package.json @@ -0,0 +1,17 @@ +{ + "name": "@wikijump/util", + "license": "agpl-3.0-or-later", + "description": "Collection of common utility methods.", + "version": "0.2.0", + "keywords": [ + "wikijump" + ], + "private": true, + "scripts": {}, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@popperjs/core": "^2.11.5", + "svelte": "^3.48.0" + } +} diff --git a/modules/util/src/decorators.ts b/modules/util/src/decorators.ts new file mode 100644 index 0000000..887ec85 --- /dev/null +++ b/modules/util/src/decorators.ts @@ -0,0 +1,49 @@ +import { perfy } from "./index" + +/** + * Decorator for measuring the performance of a function or method. + * + * @example + * + * ```ts + * let perf = 0 + * class foo { + * @measure(time => (perf = time)) + * method() { + * return "some-expensive-calculation" + * } + * } + * ``` + * + * @param callback - Callback to fire with the performance measurement taken. + */ +export function measure(callback: (perf: number, name: string) => void) { + // eslint-disable-next-line @typescript-eslint/ban-types + return (_target: Object, propertyKey: string, descriptor: PropertyDescriptor) => { + const method = descriptor.value + const async = method.constructor.name === "AsyncFunction" + + if (async) { + descriptor.value = async function (...args: any[]) { + const report = perfy() + const result = await method.apply(this, args) + const perf = report() + callback(perf, propertyKey) + return result + } + } else { + descriptor.value = function (...args: any[]) { + const report = perfy() + const result = method.apply(this, args) + const perf = report() + callback(perf, propertyKey) + return result + } + } + } +} + +/** Decorator for logging the performance of a function. */ +export const logPerformance = measure((time, name) => { + if (time >= 1) console.log(`${name}: ${time}ms`) +}) diff --git a/modules/util/src/html.ts b/modules/util/src/html.ts new file mode 100644 index 0000000..ad69344 --- /dev/null +++ b/modules/util/src/html.ts @@ -0,0 +1,43 @@ +// so we can load this module in workers: +let domParser: DOMParser +try { + domParser = new DOMParser() +} catch {} + +/** Takes a string of HTML and creates a {@link DocumentFragment}. */ +export function toFragment(html: string) { + const parsed = domParser.parseFromString(html, "text/html") + const fragment = document.createDocumentFragment() + fragment.append(...Array.from(parsed.body.children)) + return fragment +} + +/** + * **DOES NOT ESCAPE INPUT** + * + * Template string tag that creates a {@link DocumentFragment}. + */ +export function html(strings: TemplateStringsArray, ...subs: (string | string[])[]) { + const src = strings.raw.reduce((prev, cur, idx) => { + let sub = subs[idx - 1] + if (Array.isArray(sub)) sub = sub.join("") + return prev + sub + cur + }) + return toFragment(src) +} + +/** + * **DOES NOT ESCAPE INPUT** + * + * Template string tag for creating a CSS stylesheet. + */ +export function css(strings: TemplateStringsArray, ...subs: (string | string[])[]) { + const src = strings.raw.reduce((prev, cur, idx) => { + let sub = subs[idx - 1] + if (Array.isArray(sub)) sub = sub.join("") + return prev + sub + cur + }) + const style = document.createElement("style") + style.textContent = src + return style +} diff --git a/modules/util/src/index.ts b/modules/util/src/index.ts new file mode 100644 index 0000000..a1cb28f --- /dev/null +++ b/modules/util/src/index.ts @@ -0,0 +1,608 @@ +// smart idle callback polyfill +import "../vendor/request-idle-callback-polyfill.js" + +export * from "./decorators" +export * from "./html" +export * from "./pref" +export * from "./timeout" + +// https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0#gistcomment-2694461 +/** Very quickly generates a (non-secure) hash from the given string. */ +export function hash(s: string) { + let h = 0 + for (let i = 0; i < s.length; i++) { + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0 + } + return h +} + +export interface SearchOpts { + /** Starting minimum index for the search. */ + min?: number + /** Starting maximum index for the search. */ + max?: number + /** + * If true, the search will return the closest index to the desired value + * on failure. + */ + precise?: boolean +} + +/** + * Performs a binary search through an array. + * + * The comparator function should return -1 if undershooting the desired + * value, +1 if overshooting, and 0 if the value was found. + * + * The comparator can also short-circuit the search by returning true or + * false. Returning true is like returning a 0 (target found), but + * returning false induces a null return. + */ +export function search( + haystack: T[], + target: TR, + comparator: (element: T, target: TR) => number | boolean, + { min = 0, max = haystack.length - 1, precise = true }: SearchOpts = {} +) { + if (haystack.length === 0) return null + + let index = -1 + while (min <= max) { + index = min + ((max - min) >>> 1) + const cmp = comparator(haystack[index], target) + if (cmp === true || cmp === 0) return { element: haystack[index], index } + if (cmp === false) return null + if (cmp < 0) min = index + 1 + else if (cmp > 0) max = index - 1 + } + + if (index === -1) return null + + if (!precise) return { element: null, index } + + return null +} + +/** Checks if an array or object is empty. Will return true for non-objects. */ +export function isEmpty(obj: any) { + if (!obj) return true + if (obj instanceof Array) return obj.length === 0 + if (obj.constructor === Object) return Object.keys(obj).length === 0 + return true +} + +/** Creates a type that is the type of `T` if it had a known property `K`. */ +type Has = T extends { [P in K]?: infer R } + ? Omit & Record + : never + +/** + * Returns if an object `T` has a key `K`, and only returns true if the + * value of that key isn't undefined. + */ +export function has( + key: K, + obj: T +): obj is T extends Record ? Has : never { + if (typeof obj !== "object") return false + // @ts-ignore + return key in obj && obj[key] !== undefined +} + +/** Removes all properties assigned to `undefined` in an object. */ +export function removeUndefined(obj: T) { + // this wacky approach is faster as it avoids an iterator + const keys = Object.keys(obj) as (keyof T)[] + for (let i = 0; i < keys.length; i++) { + if (obj[keys[i]] === undefined) delete obj[keys[i]] + } + return obj as { [K in keyof T]: Exclude } +} + +/** Takes a string and escapes any `RegExp` sensitive characters. */ +export function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|\[\]\\]/g, "\\$&") +} + +/** + * Checks if a string has any of the provided sigils. + * + * @example + * + * ```ts + * hasSigil("!string", "!") // true + * ``` + */ +export function hasSigil( + str: unknown, + sigils: string | string[] +): str is T { + if (typeof str !== "string") return false + if (typeof sigils === "string") return str.startsWith(sigils) + for (const sigil of sigils) if (str.startsWith(sigil)) return true + return false +} + +/** Removes sigils from a string recursively. */ +export function unSigil(str: T, sigils: string | string[]): T { + if (typeof sigils === "string") { + return (str.startsWith(sigils) ? str.slice(sigils.length) : str) as T + } else { + for (const sigil of sigils) { + if (str.startsWith(sigil)) { + return unSigil(str.slice(sigil.length), sigils) as T + } + } + } + return str as T +} + +/** Creates a simple pseudo-random ID, with an optional prefix attached. */ +export function createID(prefix = "") { + const suffix = hash(Math.random() * 100 + prefix) + return `${prefix}-${suffix}` +} + +/** Converts a string into an array of codepoints. */ +export function toPoints(str: string) { + const codes: number[] = [] + for (let i = 0; i < str.length; i++) { + codes.push(str.codePointAt(i)!) + } + return codes +} + +/** + * Checks an array of codepoints against a codepoint array or a string, + * starting from a given position. + */ +export function pointsMatch(points: number[], str: string | number[], pos: number) { + if (typeof str === "string") { + for (let i = 0; i < points.length; i++) { + if (points[i] !== str.codePointAt(pos + i)) return false + } + } else { + for (let i = 0; i < points.length; i++) { + if (points[i] !== str[pos + i]) return false + } + } + return true +} + +/** + * Performance measuring utility. + * + * To use, execute the function and store the returned value. The returned + * value is a function that will end the performance timer and log the + * measured time to the console. + */ +export function perfy(meta?: string, threshold?: number): (msg?: string) => number { + const start = performance.now() + return (msg?: string) => { + const time = parseFloat((performance.now() - start).toFixed(4)) + if (meta && threshold && time > threshold) { + if (msg) { + console.log(`${msg} | ${meta}: ${time}ms`) + } else { + console.log(`${meta}: ${time}ms`) + } + } + return time + } +} + +/** Returns a promise that resolves after the specified number of miliseconds. */ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Creates and returns a promise that resolves when an invokation of + * `requestAnimationFrame()` fires its callback. + * + * @param fn - An optional function to invoke and to resolve the promise + * with its return value. If the function returns a promise, that promise + * will be waited on as well. + */ +export function animationFrame(): Promise +export function animationFrame(fn: () => T): Promise +export function animationFrame(fn?: () => any): Promise { + // simple delay + if (!fn) return new Promise(resolve => requestAnimationFrame(() => resolve())) + // callback based + return new Promise(resolve => + requestAnimationFrame(() => { + const result = fn() + if (result instanceof Promise) { + result.then(res => resolve(res)) + } else { + resolve(result) + } + }) + ) +} + +// Credit: https://gist.github.com/beaucharman/e46b8e4d03ef30480d7f4db5a78498ca +// Personally, I think this is one of the more elegant JS throttle functions. +/** + * Returns a 'throttled' variant of the given function. This function will + * only be able to execute every `limitMS` ms. Use to rate-limit functions + * for performance. You can have the first call be immediate by setting the + * third parameter to `true`. + */ +export function throttle( + fn: T, + limitMS: number, + immediate = false +) { + let timeout: number | null = null + let initialCall = true + + return function (this: any, ...args: Parameters) { + const callNow = immediate && initialCall + const next = () => { + // @ts-ignore + fn.apply(this, [...args]) + timeout = null + } + if (callNow) { + initialCall = false + next() + } + if (!timeout) timeout = setTimeout(next, limitMS) as unknown as number + } +} + +// Credit: https://gist.github.com/vincentorback/9649034 +/** Returns a 'debounced' variant of the given function. */ +export function debounce(fn: T, wait = 1) { + let timeout: any + return function (this: any, ...args: Parameters) { + clearTimeout(timeout) + timeout = setTimeout(() => void fn.call(this, ...args), wait) + } +} + +/** + * Waits until the specified function returns `true`. It will call the + * specified async function to determine the polling interval. If none is + * given, it will poll every 100ms. + */ +export async function waitFor( + conditionFn: () => Promisable, + asyncTimerFn: () => Promise = () => sleep(100) +) { + while ((await conditionFn()) === false) { + await asyncTimerFn() + continue + } + return true +} + +/** + * Returns a new 'locked' async function, constructed using the specified + * function. A locked asynchronous function will only allow a singular + * instance of itself to be running at one time. + */ +export function createLock(fn: T) { + type Return = PromiseValue> + const call = async (args: any[]) => { + return (await fn(...args)) as Return + } + + let running: Promise | null = null + + return async (...args: Parameters) => { + if (running) await running + running = call(args) + const result = await running + running = null + return result + } +} + +/** + * Returns a new 'locked' async function, constructed using the specified + * function. A locked asynchronous function will only allow a singular + * instance of itself to be running at one time. + * + * Additional calls will return null, but they will signal to the original, + * still running call to "restart" with the new given value. This means + * that the original call will only ever return the most freshly sourced result. + */ +export function createMutatingLock(fn: T) { + type Return = PromiseValue> + const call = async (args: any[]) => { + return (await fn(...args)) as Return + } + + let running: boolean + let useArgs: any[] = [] + return async (...args: Parameters): Promise => { + useArgs = args + if (running) return null + running = true + let result = await call(args) + // loop to catch if other calls mutate the arguments + // if they don't this gets skipped + while (useArgs !== args) { + // @ts-ignore + args = useArgs + result = await call(args) + } + useArgs = [] + running = false + return result + } +} + +/** + * Returns a function that will be "queued" to execute only on animation + * frames. Calling multiple times will run only once on the next + * requestAnimationFrame. + * + * @example + * + * ```ts + * const func = createAnimQueued(function target(args) => { 'foo' }) + * func() + * func() // doesn't run as the previous call is already queued + * ``` + */ +export function createAnimQueued(fn: T) { + let queued: boolean + let useArgs: any[] = [] + return (...args: Parameters): void => { + useArgs = args + if (queued !== true) { + queued = true + requestAnimationFrame(async () => { + // @ts-ignore + await fn(...useArgs) + queued = false + }) + } + } +} + +/** Safely calls `requestIdleCallback` in an awaitable `Promise`. */ +export function idleCallback(fn: () => T, timeout?: number): Promise { + return new Promise(resolve => { + requestIdleCallback( + () => { + const result = fn() + if (result instanceof Promise) result.then(resolve) + else resolve(result) + }, + { timeout } + ) + }) +} + +/** + * See `createAnimQueued` for a description of how this function works. The + * only difference is that this function uses `requestIdleCallback` instead. + * + * @see {@link createAnimQueued} + */ +export function createIdleQueued(fn: T, timeout = 100) { + let queued: boolean + let useArgs: any[] = [] + return (...args: Parameters): void => { + useArgs = args + if (queued !== true) { + queued = true + // @ts-ignore + requestIdleCallback( + async () => { + // @ts-ignore + await fn(...useArgs) + queued = false + }, + { timeout } + ) + } + } +} + +/** + * Performs a modulo operation. This differs from JavaScript's `%` + * operator, which is more of a remainder operator. + * + * @param a - The dividend. + * @param n - The divisor. + */ +export function mod(a: number, n: number) { + return ((a % n) + n) % n +} + +/** + * Replaces a range inside of a string with a substitute. + * + * @param str - The string which should have a range inside of it replaced. + * @param from - The start of the replacement range. + * @param to - The end of the replacement range. + * @param sub - The replacement/substitute string. + */ +export function replaceRange(str: string, from: number, to: number, sub: string) { + return str.substring(0, from) + sub + str.substring(to) +} + +/** + * Uppercases a string. + * + * @param str - The string to uppercase. + * @param locale - Uses a locale, or a list of locales, case mapping if + * provided. This usually won't be needed, as JS tries to account for + * non-ASCII/Latin text when handling casing. + */ +export function uppercase(str: string, locale?: string | string[]) { + return locale ? str.toLocaleUpperCase(locale) : str.toUpperCase() +} + +/** + * Lowercases a string. + * + * @param str - The string to lowercase. + * @param locale - Uses a locale, or a list of locales, case mapping if + * provided. This usually won't be needed, as JS tries to account for + * non-ASCII/Latin text when handling casing. + */ +export function lowercase(str: string, locale?: string | string[]) { + return locale ? str.toLocaleLowerCase(locale) : str.toLowerCase() +} + +/** + * Titlecases a string. + * + * @param str - The string to titlecase. + * @param locale - Uses a locale, or a list of locales, case mapping if + * provided. This usually won't be needed, as JS tries to account for + * non-ASCII/Latin text when handling casing. + */ +export function titlecase(str: string, locale?: string | string[]) { + return replaceRange(lowercase(str, locale), 0, 1, uppercase(str[0], locale)) +} + +/** + * Determines if a string is titlecased. + * + * @param str - The string to check. + * @param locale - Uses a locale, or a list of locales, case mapping if + * provided. This usually won't be needed, as JS tries to account for + * non-ASCII/Latin text when handling casing. + */ +export function isTitlecased(str: string, locale?: string | string[]) { + return uppercase(str[0], locale) === str[0] +} + +/** + * Determines if a string is completely uppercased. + * + * @param str - The string to check. + * @param locale - Uses a locale, or a list of locales, case mapping if + * provided. This usually won't be needed, as JS tries to account for + * non-ASCII/Latin text when handling casing. + */ +export function isUppercased(str: string, locale?: string | string[]) { + return uppercase(str, locale) === str +} + +/** + * Determines if a string is completely lowercased. + * + * @param str - The string to check. + * @param locale - Uses a locale, or a list of locales, case mapping if + * provided. This usually won't be needed, as JS tries to account for + * non-ASCII/Latin text when handling casing. + */ +export function isLowercased(str: string, locale?: string | string[]) { + return lowercase(str, locale) === str +} + +/** Helper for turning a relative `?url` import into an absolute path. */ +export async function url(imp: Promise) { + return new URL((await imp).default, import.meta.url).toString() +} + +/** + * Deduplicates an array. Does not mutate the original array. + * + * @param arr - The array to deduplicate. + * @param insert - Additional values to insert into the array, if desired. + */ +export function dedupe(arr: T, ...insert: T) { + return [...new Set([...arr, ...insert])] as T +} + +/** + * Simple helper for creating lazy singletons. Use the `.get()` method to + * get the current instance. If `.get()` is being called for the first + * time, the instance will be constructed using a factory function. + */ +export class LazySingleton { + /** The singleton instance. */ + private instance?: T + + /** @param factory - The factory function to use to construct the instance. */ + constructor(private factory: () => T) {} + + /** Gets the current instance. */ + get() { + return !this.instance ? (this.instance = this.factory()) : this.instance + } + + /** Is `true` if the instance has ever been contructed. */ + get loaded() { + return Boolean(this.instance) + } +} + +// adapted from: https://stackoverflow.com/a/34920444 +// this, as far as I can tell, is basically the fastest way to do this. +// there is a simpler method involving `TextEncoder`, but for large strings +// that method is slower than this. It also has to allocate a new buffer +// every time, which is a waste of memory. + +/** + * Gets the byte length of a string. + * + * @param str - The string to get the byte length of. + */ +export function byteLength(str: string) { + // assuming the String is UCS-2(aka UTF-16) encoded + let len = 0 + + for (let i = 0; i < str.length; i++) { + let high = str.charCodeAt(i) + + // [0x0000, 0x007F] + if (high < 0x0080) len += 1 + // [0x0080, 0x07FF] + else if (high < 0x0800) len += 2 + // [0x0800, 0xD7FF] + else if (high < 0xd800) len += 3 + // [0xD800, 0xDBFF] + else if (high < 0xdc00) { + let low = str.charCodeAt(++i) + if (i < str.length && low >= 0xdc00 && low <= 0xdfff) { + // followed by [0xDC00, 0xDFFF] + len += 4 + } else { + throw new Error("malformed UTF-16 string") + } + } + // [0xDC00, 0xDFFF] + else if (high < 0xe000) { + throw new Error("malformed UTF-16 string") + } + // [0xE000, 0xFFFF] + else len += 3 + } + + return len +} + +const decoder = new TextDecoder() +const encoder = new TextEncoder() + +/** + * Convert a string or generic buffer into an `ArrayBuffer`. + * + * @param buffer - The string, `ArrayBuffer`, or typed array to convert. + */ +export function encode(buffer: string | ArrayBufferLike | ArrayBufferView) { + if (typeof buffer === "string") return encoder.encode(buffer).buffer + if ("buffer" in buffer) return buffer.buffer + if (buffer instanceof ArrayBuffer) return buffer + throw new TypeError("Expected a string, ArrayBuffer, or typed array!") +} + +/** + * Decode an `ArrayBuffer` into a string. + * + * @param buffer - The `ArrayBuffer` to decode. + */ +export function decode(buffer: ArrayBufferLike | ArrayBufferView) { + return decoder.decode(buffer) +} diff --git a/modules/util/src/pref.ts b/modules/util/src/pref.ts new file mode 100644 index 0000000..b17ea4f --- /dev/null +++ b/modules/util/src/pref.ts @@ -0,0 +1,163 @@ +import { writable, type Updater, type Writable } from "svelte/store" + +/** + * `localStorage`-based smart preference handler, with support for + * observables or proxied objects. + */ +export class PreferenceHandler { + /** + * @param prefix - The namespace to use for the preference handler. + * Defaults to `"_user-pref_"` + */ + constructor(private prefix = "_user-pref_") {} + + /** + * Returns the name given with a prefix namespace, to prevent collisions + * with other items in `localStorage`. + */ + private processName(name: string) { + if (name.startsWith(this.prefix)) return name + return (name = this.prefix + name) + } + + /** + * Attempt to retrieve the preference with the given name. If it isn't + * found, the fallback value will instead be returned. + * + * @param name - The name/key for the preference. + * @param fallback - The default value to return if the preference doesn't exist. + */ + get(name: string, fallback: T): T + get(name: string, fallback?: T): T | undefined + get(name: string, fallback?: T): T | undefined { + name = this.processName(name) + const storedPreference = localStorage.getItem(name) + if (storedPreference) return JSON.parse(storedPreference) as T + else return fallback + } + + /** + * Sets the preference with the given name to the given value. Passing an + * empty string will remove the preference from storage. + * + * The `value` given **must** be in such a format that it can be + * stringified by `JSON.stringify`. Otherwise, data _will_ be lost. + * + * @param name - The name/key for the preference. + * @param value - The value to set. An empty string removes the preference. + */ + set(name: string, value: T) { + name = this.processName(name) + if (!value) localStorage.removeItem(name) + else localStorage.setItem(name, JSON.stringify(value)) + return value + } + + /** + * Removes a preference from storage. + * + * @param name - The name/key for the preference. + */ + remove(name: string) { + name = this.processName(name) + localStorage.removeItem(name) + } + + /** + * Returns if the requested preference is available in storage. + * + * @param name - The name/key for the preference. + */ + has(name: string) { + name = this.processName(name) + return Boolean(localStorage.getItem(name)) + } + + /** Returns the list of preferences currently stored. */ + stored() { + const len = localStorage.length + const keys = new Set() + for (let idx = 0; idx < len; idx++) { + const name = localStorage.key(idx) + if (name?.startsWith(this.prefix)) { + keys.add(name.substr(this.prefix.length)) + } + } + return [...keys] + } + + /** + * Returns a writable observable store that maps to the given preference. + * Works with deep accesses and writes. + * + * @example + * + * ```svelte + * + * ``` + * + * @param name - The name/key for the preference. + * @param fallback - The default value to return if the preference doesn't exist. + */ + bind(name: string, fallback?: T): Writable { + const handlerGet = this.get.bind(this) + const handlerSet = this.set.bind(this) + const store = writable(this.get(name, fallback ?? ({} as T))) + return { + subscribe: store.subscribe, + set: (val: T) => { + // make a new object so we don't mutate old ones + store.set(typeof val === "object" && !Array.isArray(val) ? { ...val } : val) + handlerSet(name, val) + }, + update(cur: Updater) { + cur.call(this, handlerGet(name, fallback ?? ({} as T))) + } + } + } + + /** + * Retrieves a 'wrapped' proxy object, or creates one if needed. Setting + * items on this object will automatically cause the object to be stored. + * This can be used to store a record of preferences without needing to + * use an observable store. + * + * @example + * + * ```ts + * type Settings = { foo: true; bar: { foo: false } } + * const settings = Pref.wrap("settings") + * settings.foo = false // updates localStorage + * settings.bar.foo = true // also updates localStorage, despite deeply accessing + * ``` + */ + wrap(name: string, fallback: T): T { + const wrapped = this.get(name, fallback) + const handler: ProxyHandler = { + // handle nested objects by proxying them with the same handler + get: (target, prop) => { + const val = Reflect.get(target, prop) + return typeof val === "object" ? new Proxy(val, handler) : val + }, + // fire setter function whenever the object has a property set (even recursively) + set: (target, prop, val) => { + Reflect.set(target, prop, val) + this.set(name, wrapped) + return true + } + } + return new Proxy(wrapped, handler) + } +} + +/** + * Pre-made {@link PreferenceHandler} with the default `"_user-pref_"` prefix set. + * + * @see {@link PreferenceHandler} + */ +export const Pref = new PreferenceHandler() diff --git a/modules/util/src/timeout.ts b/modules/util/src/timeout.ts new file mode 100644 index 0000000..398efd0 --- /dev/null +++ b/modules/util/src/timeout.ts @@ -0,0 +1,188 @@ +/** + * Replacement class for `setTimeout`, with tons of utility features. + * Additionally provides some extra safety and avoids typing issues with + * `NodeJS.Timeout`. + */ +export class Timeout { + // typed as any to avoid NodeJS.Timeout + private declare timeout: any + + /** Function that resolves the timeout's `promise` Promise. */ + private declare promiseResolve: (resolved: T) => void + + /** The delay given before the callback should be fired. */ + declare delay: number + + /** The time the timeout will end by. */ + declare ends: Date + + /** The time the timeout was started. */ + declare started: Date + + /** + * A promise that resolves when the timeout expires. If the timeout is + * reset after it has expired, this property will be updated, so make + * sure to access this property directly and do not store it. + */ + declare promise: Promise + + /** + * The final value returned by the callback function. Always undefined if + * the timeout is running. + */ + declare value?: T + + /** + * The callback that will be fired when the timeout expires. This **will + * not** be the same function that was given when this timeout was + * constructed, so identity comparisons won't work. + */ + private declare cb?: () => void + + /** + * @param delay - The delay for the timer. Set to `null` for an immediate timer. + * @param cb - The callback that will be fired when the timeout expires. + * Is optional. + * @param immediate - If true, the callback will be fired immediately. + * Defaults to true. + */ + constructor(delay?: number | null, cb?: (() => T) | null, immediate = true) { + this.reset(delay, cb) + if (!immediate) this.clear() + } + + /** True if the timeout is running. */ + get running() { + return this.timeout !== undefined + } + + /** Function for fulfilling the thenable contract. */ + then(resolve?: (value: T) => T | PromiseLike) { + return this.promise.then(resolve) + } + + /** The amount of time remaining before the timeout expires, in milliseconds. */ + remaining() { + if (!this.ends || !this.started) return 0 + const remaining = this.ends.getTime() - new Date().getTime() + return remaining > 0 ? remaining : 0 + } + + // apparently, this is how you do typeguards for classes? + // it's a bit weird, it appears like it's a shorthand for + // `this is Timeout & { value: T } + /** Returns true if the timeout has expired already. */ + expired(): this is { value: T } { + return this.timeout === undefined + } + + /** Clears the timeout and prevents it from expiring. */ + clear() { + if (!this.timeout) return + clearTimeout(this.timeout) + this.timeout = undefined + } + + /** + * Extends the timeout, adding the given delay to the current time + * remaining. Does nothing if the timeout has already expired. + * + * @param delay - The delay to add to the current time remaining. + */ + extend(delay: number) { + if (this.expired()) return + this.reset(this.remaining() + delay) + } + + /** + * Resets the timeout. Optionally allows changing the delay and callback. + * + * @param delay - The delay between now and when the callback should be + * fired. Set to `null` to have a "tick" timer. + * @param cb - The callback that will be fired when the timeout expires. + * Provide `null` to get rid of the current callback. + */ + reset(delay?: number | null, cb?: (() => T) | null) { + if (cb && typeof cb !== "function") { + console.error("Avoided potential string eval in timeout!") + throw new Error("Timeout callback must be a function") + } + + if (this.expired() || !this.promise) { + this.promise = new Promise(resolve => { + this.promiseResolve = resolve + }) + } + + if (typeof delay === "number") this.delay = delay + else if (delay === null) this.delay = 0 + + if (cb) { + this.cb = () => { + const out = cb() + this.value = out + this.promiseResolve(out) + } + } else if (cb === null) { + this.cb = undefined + } + + if (this.expired()) this.started = new Date() + this.ends = new Date(this.started.getTime() + this.delay) + this.value = undefined + this.clear() // make sure we end the old timeout + + this.timeout = setTimeout(() => { + if (this.cb) this.cb() + this.timeout = undefined + }, this.delay) + } +} + +/** + * Creates a new {@link Timeout}. + * + * @param delay - The delay between now and when the callback should be fired. + * @param cb - The callback that will be fired when the timeout expires. + */ +export function timeout(delay: number, cb?: () => T) { + return new Timeout(delay, cb) +} + +/** + * Creates a new {@link Timeout} that resolves as soon as possible. + * + * @param cb - The callback that will be fired when the timeout expires. + */ +export function tick(cb?: () => T) { + return new Timeout(0, cb) +} + +/** + * Clears a {@link Timeout}. + * + * @param timeout - The timeout to clear. + */ +function clearTimeoutClass(timeout: Timeout | undefined | null) { + if (!timeout) return + timeout.clear() +} + +export { clearTimeoutClass as clearTimeout } + +/** Symbol for identifying timed out promises. */ +export const TIMED_OUT_SYMBOL = Symbol("timedout") + +/** + * Utility for handling promise timeouts. If the {@link TIMED_OUT_SYMBOL} + * symbol is returned, the promise timed out. + * + * @param promise - The promise to wrap. + * @param time - The duration to use. + */ +export async function timedout(promise: Promise, time: number) { + const timer = timeout(time, () => TIMED_OUT_SYMBOL) + const result = await Promise.race([promise, timer.promise]) + if (result !== TIMED_OUT_SYMBOL) timer.clear() + return result +} diff --git a/modules/util/tests/wj-util.ts b/modules/util/tests/wj-util.ts new file mode 100644 index 0000000..517712b --- /dev/null +++ b/modules/util/tests/wj-util.ts @@ -0,0 +1,191 @@ +import { assert, describe, it } from "vitest" +import * as lib from "../src/index" + +// TODO: search tests +// TODO: toPoints +// TODO: pointsMatch + +describe("@wikijump/util", () => { + it("hash", () => { + const output = lib.hash("test") + assert.equal(output, 3556498) + }) + + it("isEmpty", () => { + const { isEmpty } = lib + assert.ok(isEmpty([])) + assert.ok(isEmpty({})) + assert.notOk(isEmpty(["foo"])) + assert.notOk(isEmpty({ foo: "foo" })) + assert.ok(isEmpty(undefined)) + }) + + it("has", () => { + const { has } = lib + assert.notOk(has("foo", {})) + assert.ok(has("foo", { foo: "foo" })) + assert.notOk(has("foo", { foo: undefined })) + assert.notOk(has("foo", undefined)) + }) + + it("removeUndefined", () => { + const { removeUndefined } = lib + // @ts-ignore + assert.deepEqual(removeUndefined({ foo: "foo", bar: undefined }), { foo: "foo" }) + }) + + it("escapeRegExp", () => { + const { escapeRegExp } = lib + const str = ".*+?^${}()|[]\\" + assert.equal(escapeRegExp(str), String.raw`\.\*\+\?\^\$\{\}\(\)\|\[\]\\`) + }) + + it("hasSigil", () => { + const { hasSigil } = lib + assert.ok(hasSigil("!foo", "!")) + assert.ok(hasSigil("!foo", ["$", "!"])) + assert.notOk(hasSigil("foo", ["$", "!"])) + }) + + it("unSigil", () => { + const { unSigil } = lib + assert.equal(unSigil("!foo", "!"), "foo") + assert.equal(unSigil("$!!foo", ["$", "!"]), "foo") + }) + + it("createID", () => { + const { createID } = lib + // there isn't really a good test for this lol + assert.isString(createID("foo")) + }) + + it("perfy", () => { + const { perfy } = lib + const exec = perfy() + assert.isNumber(exec()) + }) + + it("sleep", async () => { + let flip = false + const promise = lib.sleep(10).then(() => (flip = true)) + assert.notOk(flip) + await promise + assert.ok(flip) + }) + + it("animationFrame", async () => { + let flip = false + const promise = lib.animationFrame().then(() => (flip = true)) + assert.notOk(flip) + await promise + assert.ok(flip) + }) + + it("throttle", async () => { + let flip = false + const func = () => { + assert.notOk(flip) + flip = true + } + const throttled = lib.throttle(func, 50) + throttled() + throttled() // should be ignored, function called too fast + }) + + it("throttle immediate", async () => { + let count = 0 + const func = () => { + assert.notEqual(count, 2) + count++ + } + const throttled = lib.throttle(func, 50, true) + throttled() + throttled() // first call doesn't count against throttling + throttled() // should be ignored, function called too fast + }) + + it("debounce", async () => { + let count = 0 + const func = () => count++ + const debounced = lib.debounce(func) + debounced() + debounced() // ignored + debounced() // ignored + await lib.sleep(10) + assert.equal(count, 1) + debounced() + debounced() // ignored + debounced() // ignored + await lib.sleep(10) + assert.equal(count, 2) + }) + + it("waitFor", async () => { + let flip = false + let failed = true + + lib.sleep(50).then(() => (flip = true)) + lib.sleep(150).then(() => assert.notOk(failed)) + + let condition = () => flip === true + await lib.waitFor(condition) + failed = false + }) + + it("createLock", async () => { + let busy = false + const func = async (input: boolean) => { + assert.equal(input, true) + assert.notOk(busy) + busy = true + await lib.sleep(50) + busy = false + } + const locked = lib.createLock(func) + locked(true) + locked(true) + }) + + it("createAnimQueued", async () => { + let flip = false + const func = () => { + assert.notOk(flip) + flip = true + } + const queued = lib.createAnimQueued(func) + queued() + queued() // should be ignored, function called too fast + }) + + it("idleCallback", async () => { + let flip = false + const promise = lib.idleCallback(() => (flip = true)) + assert.notOk(flip) + await promise + assert.ok(flip) + }) + + it("createIdleQueued", async () => { + let flip = false + const func = () => { + assert.notOk(flip) + flip = true + } + const queued = lib.createIdleQueued(func) + queued() + queued() // should be ignored, function called too fast + }) + + it("toFragment", () => { + const html = "
foo
" + const fragment = lib.toFragment(html) + assert.instanceOf(fragment, DocumentFragment) + assert.equal(fragment.firstElementChild!.outerHTML, html) + }) + + it("html", () => { + const fragment = lib.html`
${["foo", "bar"]} ${"foo"}
` + assert.instanceOf(fragment, DocumentFragment) + assert.equal(fragment.firstElementChild!.outerHTML, "
foobar foo
") + }) +}) diff --git a/modules/util/tsconfig.json b/modules/util/tsconfig.json new file mode 100644 index 0000000..03cbf10 --- /dev/null +++ b/modules/util/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "composite": true + }, + "extends": "../../tsconfig.json", + "include": ["src/**/*", "tests/**/*", "../_types/**/*", "vendor/**/*"] +} diff --git a/modules/util/vendor/request-idle-callback-polyfill.js b/modules/util/vendor/request-idle-callback-polyfill.js new file mode 100644 index 0000000..4de838e --- /dev/null +++ b/modules/util/vendor/request-idle-callback-polyfill.js @@ -0,0 +1,211 @@ +// A request idle callback polyfill by Alexander Farkas. +// https://github.com/aFarkas/requestIdleCallback + +(function (factory) { + globalThis.idleCallbackShim = factory(); +}(function(){ + 'use strict'; + var scheduleStart, throttleDelay, lazytimer, lazyraf; + var root = globalThis.window ? globalThis.window : globalThis; + var requestAnimationFrame = root.cancelRequestAnimationFrame && root.requestAnimationFrame || setTimeout; + var cancelRequestAnimationFrame = root.cancelRequestAnimationFrame || clearTimeout; + var tasks = []; + var runAttempts = 0; + var isRunning = false; + var remainingTime = 7; + var minThrottle = 35; + var throttle = 125; + var index = 0; + var taskStart = 0; + var tasklength = 0; + var IdleDeadline = { + get didTimeout(){ + return false; + }, + timeRemaining: function(){ + var timeRemaining = remainingTime - (performance.now() - taskStart); + return Math.max(0, timeRemaining) + }, + }; + var setInactive = debounce(function(){ + remainingTime = 22; + throttle = 66; + minThrottle = 0; + }); + + function debounce(fn){ + var id, timestamp; + var wait = 99; + var check = function(){ + var last = (performance.now()) - timestamp; + + if (last < wait) { + id = setTimeout(check, wait - last); + } else { + id = null; + fn(); + } + }; + return function(){ + timestamp = performance.now(); + if(!id){ + id = setTimeout(check, wait); + } + }; + } + + function abortRunning(){ + if(isRunning){ + if(lazyraf){ + cancelRequestAnimationFrame(lazyraf); + } + if(lazytimer){ + clearTimeout(lazytimer); + } + isRunning = false; + } + } + + function onInputorMutation(){ + if(throttle != 125){ + remainingTime = 7; + throttle = 125; + minThrottle = 35; + + if(isRunning) { + abortRunning(); + scheduleLazy(); + } + } + setInactive(); + } + + function scheduleAfterRaf() { + lazyraf = null; + lazytimer = setTimeout(runTasks, 0); + } + + function scheduleRaf(){ + lazytimer = null; + requestAnimationFrame(scheduleAfterRaf); + } + + function scheduleLazy(){ + + if(isRunning){return;} + throttleDelay = throttle - (performance.now() - taskStart); + + scheduleStart = performance.now(); + + isRunning = true; + + if(minThrottle && throttleDelay < minThrottle){ + throttleDelay = minThrottle; + } + + if(throttleDelay > 9){ + lazytimer = setTimeout(scheduleRaf, throttleDelay); + } else { + throttleDelay = 0; + scheduleRaf(); + } + } + + function runTasks(){ + var task, i, len; + var timeThreshold = remainingTime > 9 ? + 9 : + 1 + ; + + taskStart = performance.now(); + isRunning = false; + + lazytimer = null; + + if(runAttempts > 2 || taskStart - throttleDelay - 50 < scheduleStart){ + for(i = 0, len = tasks.length; i < len && IdleDeadline.timeRemaining() > timeThreshold; i++){ + task = tasks.shift(); + tasklength++; + if(task){ + task(IdleDeadline); + } + } + } + + if(tasks.length){ + scheduleLazy(); + } else { + runAttempts = 0; + } + } + + function requestIdleCallbackShim(task){ + index++; + tasks.push(task); + scheduleLazy(); + return index; + } + + function cancelIdleCallbackShim(id){ + var index = id - 1 - tasklength; + if(tasks[index]){ + tasks[index] = null; + } + } + + if(!root.requestIdleCallback || !root.cancelIdleCallback){ + root.requestIdleCallback = requestIdleCallbackShim; + root.cancelIdleCallback = cancelIdleCallbackShim; + + if (root !== globalThis) { + globalThis.requestIdleCallback = requestIdleCallbackShim; + globalThis.cancelIdleCallback = cancelIdleCallbackShim; + } + + if(root.document && document.addEventListener){ + root.addEventListener('scroll', onInputorMutation, true); + root.addEventListener('resize', onInputorMutation); + + document.addEventListener('focus', onInputorMutation, true); + document.addEventListener('mouseover', onInputorMutation, true); + ['click', 'keypress', 'touchstart', 'mousedown'].forEach(function(name){ + document.addEventListener(name, onInputorMutation, {capture: true, passive: true}); + }); + + if(root.MutationObserver){ + new MutationObserver( onInputorMutation ).observe( document.documentElement, {childList: true, subtree: true, attributes: true} ); + } + } + } else { + try{ + root.requestIdleCallback(function(){}, {timeout: 0}); + } catch(e){ + (function(rIC){ + var timeRemainingProto, timeRemaining; + root.requestIdleCallback = function(fn, timeout){ + if(timeout && typeof timeout.timeout == 'number'){ + return rIC(fn, timeout.timeout); + } + return rIC(fn); + }; + if(root.IdleCallbackDeadline && (timeRemainingProto = IdleCallbackDeadline.prototype)){ + timeRemaining = Object.getOwnPropertyDescriptor(timeRemainingProto, 'timeRemaining'); + if(!timeRemaining || !timeRemaining.configurable || !timeRemaining.get){return;} + Object.defineProperty(timeRemainingProto, 'timeRemaining', { + value: function(){ + return timeRemaining.get.call(this); + }, + enumerable: true, + configurable: true, + }); + } + })(root.requestIdleCallback) + } + } + + return { + request: requestIdleCallbackShim, + cancel: cancelIdleCallbackShim, + }; +})); diff --git a/package.json b/package.json index 83613fe..1e057fc 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,22 @@ "preview": "vite preview" }, "dependencies": { + "@popperjs/core": "^2.11.8", "@types/node": "^18.16.15", "@types/wicg-file-system-access": "^2020.9.6", "@vscode-ftml/ftml-wasm": "^1.18.1", "@wikijump/ftml-components": "link:modules\\ftml-components", "autoprefixer": "^10.4.14", + "codemirror": "^6.0.1", + "comlink": "^4.4.1", "esbuild": "^0.17.19", "eslint": "^8.41.0", + "hfmath": "^0.0.2", "include-media": "^2.0.0", "node-sass-import-once": "^1.2.0", "postcss-merge-queries": "^1.0.3", + "svelte": "^3.59.1", + "svelte-store": "^0.0.2", "terser": "^5.17.6", "throttle-typescript": "^1.1.0", "tippy.js": "6.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a97409..aa96571 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,18 +1,24 @@ lockfileVersion: 5.4 specifiers: + '@popperjs/core': ^2.11.8 '@types/node': ^18.16.15 '@types/wicg-file-system-access': ^2020.9.6 '@vscode-ftml/ftml-wasm': ^1.18.1 '@wikijump/ftml-components': link:modules\ftml-components autoprefixer: ^10.4.14 + codemirror: ^6.0.1 + comlink: ^4.4.1 esbuild: ^0.17.19 eslint: ^8.41.0 + hfmath: ^0.0.2 include-media: ^2.0.0 node-sass-import-once: ^1.2.0 postcss-merge-queries: ^1.0.3 sass: ^1.62.1 sass-loader: ^10.4.1 + svelte: ^3.59.1 + svelte-store: ^0.0.2 terser: ^5.17.6 throttle-typescript: ^1.1.0 tippy.js: 6.2.5 @@ -23,16 +29,22 @@ specifiers: yaml: ^2.3.1 dependencies: + '@popperjs/core': 2.11.8 '@types/node': 18.16.15 '@types/wicg-file-system-access': 2020.9.6 '@vscode-ftml/ftml-wasm': 1.18.1 '@wikijump/ftml-components': link:modules/ftml-components autoprefixer: 10.4.14 + codemirror: 6.0.1 + comlink: 4.4.1 esbuild: 0.17.19 eslint: 8.41.0 + hfmath: 0.0.2 include-media: 2.0.0 node-sass-import-once: 1.2.0 postcss-merge-queries: 1.0.3 + svelte: 3.59.1 + svelte-store: 0.0.2 terser: 5.17.6 throttle-typescript: 1.1.0 tippy.js: 6.2.5 @@ -48,6 +60,63 @@ devDependencies: packages: + /@codemirror/autocomplete/6.7.1: + resolution: {integrity: sha512-hSxf9S0uB+GV+gBsjY1FZNo53e1FFdzPceRfCfD1gWOnV6o21GfB5J5Wg9G/4h76XZMPrF0A6OCK/Rz5+V1egg==} + dependencies: + '@codemirror/language': 6.7.0 + '@codemirror/state': 6.2.1 + '@codemirror/view': 6.12.0 + '@lezer/common': 1.0.2 + dev: false + + /@codemirror/commands/6.2.4: + resolution: {integrity: sha512-42lmDqVH0ttfilLShReLXsDfASKLXzfyC36bzwcqzox9PlHulMcsUOfHXNo2X2aFMVNUoQ7j+d4q5bnfseYoOA==} + dependencies: + '@codemirror/language': 6.7.0 + '@codemirror/state': 6.2.1 + '@codemirror/view': 6.12.0 + '@lezer/common': 1.0.2 + dev: false + + /@codemirror/language/6.7.0: + resolution: {integrity: sha512-4SMwe6Fwn57klCUsVN0y4/h/iWT+XIXFEmop2lIHHuWO0ubjCrF3suqSZLyOQlznxkNnNbOOfKe5HQbQGCAmTg==} + dependencies: + '@codemirror/state': 6.2.1 + '@codemirror/view': 6.12.0 + '@lezer/common': 1.0.2 + '@lezer/highlight': 1.1.6 + '@lezer/lr': 1.3.5 + style-mod: 4.0.3 + dev: false + + /@codemirror/lint/6.2.1: + resolution: {integrity: sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==} + dependencies: + '@codemirror/state': 6.2.1 + '@codemirror/view': 6.12.0 + crelt: 1.0.6 + dev: false + + /@codemirror/search/6.4.0: + resolution: {integrity: sha512-zMDgaBXah+nMLK2dHz9GdCnGbQu+oaGRXS1qviqNZkvOCv/whp5XZFyoikLp/23PM9RBcbuKUUISUmQHM1eRHw==} + dependencies: + '@codemirror/state': 6.2.1 + '@codemirror/view': 6.12.0 + crelt: 1.0.6 + dev: false + + /@codemirror/state/6.2.1: + resolution: {integrity: sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw==} + dev: false + + /@codemirror/view/6.12.0: + resolution: {integrity: sha512-xNHvbJBc2v8JuEcIGOck6EUGShpP+TYGCEMVEVQMYxbFXfMhYnoF3znxB/2GgeKR0nrxBs+nhBupiTYQqCp2kw==} + dependencies: + '@codemirror/state': 6.2.1 + style-mod: 4.0.3 + w3c-keyname: 2.2.7 + dev: false + /@esbuild/android-arm/0.17.19: resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} engines: {node: '>=12'} @@ -315,6 +384,22 @@ packages: '@jridgewell/resolve-uri': 3.1.0 '@jridgewell/sourcemap-codec': 1.4.14 + /@lezer/common/1.0.2: + resolution: {integrity: sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==} + dev: false + + /@lezer/highlight/1.1.6: + resolution: {integrity: sha512-cmSJYa2us+r3SePpRCjN5ymCqCPv+zyXmDl0ciWtVaNiORT/MxM7ZgOMQZADD0o51qOaOg24qc/zBViOIwAjJg==} + dependencies: + '@lezer/common': 1.0.2 + dev: false + + /@lezer/lr/1.3.5: + resolution: {integrity: sha512-Kye0rxYBi+OdToLUN2tQfeH5VIrpESC6XznuvxmIxbO1lz6M1C90vkjMNYoX1SfbUcuvoPXvLYsBquZ//77zVQ==} + dependencies: + '@lezer/common': 1.0.2 + dev: false + /@nodelib/fs.scandir/2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -641,6 +726,18 @@ packages: engines: {node: '>=6.0'} dev: true + /codemirror/6.0.1: + resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} + dependencies: + '@codemirror/autocomplete': 6.7.1 + '@codemirror/commands': 6.2.4 + '@codemirror/language': 6.7.0 + '@codemirror/lint': 6.2.1 + '@codemirror/search': 6.4.0 + '@codemirror/state': 6.2.1 + '@codemirror/view': 6.12.0 + dev: false + /color-convert/2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -652,6 +749,10 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: false + /comlink/4.4.1: + resolution: {integrity: sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q==} + dev: false + /commander/2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -659,6 +760,10 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: false + /crelt/1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + dev: false + /cross-spawn/7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -976,6 +1081,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + /hfmath/0.0.2: + resolution: {integrity: sha512-cKUi0yiQLGfLgs8+3Iw5nAiqSH13Knp7vCf0G1vlF5nfiKKO1XmxNagMvyp0F4ZvUNaHpRGTkmc7nowCy1S58g==} + dev: false + /ignore/5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} @@ -1424,6 +1533,10 @@ packages: engines: {node: '>=8'} dev: false + /style-mod/4.0.3: + resolution: {integrity: sha512-78Jv8kYJdjbvRwwijtCevYADfsI0lGzYJe4mMFdceO8l75DFFDoqBhR1jVDicDRRaX4//g1u9wKeo+ztc2h1Rw==} + dev: false + /supports-color/7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1438,6 +1551,15 @@ packages: has-flag: 4.0.0 dev: true + /svelte-store/0.0.2: + resolution: {integrity: sha512-Kq43XbSE/mQEw4bENBBpI8MWJo4D09GakmG1nP2bN+so24VVK7j0On3Zmmxpl75dBYZnlaneYmDr7GBktRJmGw==} + dev: false + + /svelte/3.59.1: + resolution: {integrity: sha512-pKj8fEBmqf6mq3/NfrB9SLtcJcUvjYSWyePlfCqN9gujLB25RitWK8PvFzlwim6hD/We35KbPlRteuA6rnPGcQ==} + engines: {node: '>= 8'} + dev: false + /tapable/2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} @@ -1570,6 +1692,10 @@ packages: fsevents: 2.3.2 dev: false + /w3c-keyname/2.2.7: + resolution: {integrity: sha512-XB8aa62d4rrVfoZYQaYNy3fy+z4nrfy2ooea3/0BnBzXW0tSdZ+lRgjzBZhk0La0H6h8fVyYCxx/qkQcAIuvfg==} + dev: false + /watchpack/2.4.0: resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} engines: {node: '>=10.13.0'} diff --git a/resources/css/main.scss b/resources/css/main.scss index 268267f..ec32f1b 100644 --- a/resources/css/main.scss +++ b/resources/css/main.scss @@ -29,7 +29,7 @@ // @import "components/links"; // @import "components/navbar-element"; // @import "components/scrollbar"; -// @import "components/sheaf"; +@import "components/sheaf"; // @import "components/sidebar-nav-element"; // @import "components/tippy"; // @import "components/wikitext"; diff --git a/src/css/code.css b/src/css/code.css new file mode 100644 index 0000000..80beb60 --- /dev/null +++ b/src/css/code.css @@ -0,0 +1,39 @@ +:root { + --colcode-background: #F0F0F2; + --colcode-hover: #E8E8ED; + --colcode-border: #D2D2D7; + --colcode-selection: #3296FF20; + --colcode-accent: #1262B5; + + --colcode-content: #222222; + --colcode-comment: #666666; + --colcode-commentdoc: #666688; + --colcode-punct: #818D94; + --colcode-markup: #2269A8; + --colcode-link: #64A0FF; + + --colcode-special: #EC1C12; + --colcode-invalid: #FF3214; + + --colcode-inserted: #54D169; + --colcode-changed: #FF9614; + --colcode-important: #FFCB6BEE; + --colcode-highlight: #C878C8; + --colcode-note: #5694D6; + + --colcode-keyword: #C708FF; + --colcode-logical: #C708FF; + --colcode-operator: #41B2D1; + --colcode-storage: #41B2D1; + --colcode-string: #10AB00; + --colcode-entity: #10AB00; + --colcode-type: #9C8922; + --colcode-ident: #005296; + --colcode-function: #3394CC; + --colcode-constant: #BB5F00; + --colcode-property: #EC1C12; + --colcode-tag: #EC1C12; + --colcode-id: #3394CC; + --colcode-class: #10AB00; + --colcode-attribute: #FFCB6B; +} \ No newline at end of file diff --git a/src/css/init.css b/src/css/init.css index 45960f0..ed09997 100644 --- a/src/css/init.css +++ b/src/css/init.css @@ -1,3 +1,4 @@ +@import url(./code.css); .wj-user-info { display: inline-block; } diff --git a/src/css/wikidot.css b/src/css/wikidot.css index 957f249..c8464b6 100644 --- a/src/css/wikidot.css +++ b/src/css/wikidot.css @@ -125,4 +125,8 @@ a#account-topbutton:focus + div#account-options { .wj-collapsible[open][data-show-top] > .wj-collapsible-button-top > .wj-collapsible-show-text { display: unset; +} + +code, .code { + background-color: unset; } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 63a5ca8..3dc71e3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import '@/../resources/css/main.scss'; +import '@wikijump/ftml-components/src/index.ts'; import css from './css/wikidot.css'; import init from './css/init.css'; import collapsible from './css/collapsible.css'; diff --git a/vite.config.js b/vite.config.js index 58264f3..e1fdfd5 100644 --- a/vite.config.js +++ b/vite.config.js @@ -54,7 +54,8 @@ module.exports = { resolve: { alias: { '@': resolve(__dirname, root), - '/node_modules/': path.resolve(__dirname, 'node_modules/') + '/node_modules/': path.resolve(__dirname, 'node_modules/'), + "@wikijump": resolve(__dirname, 'modules/'), }, }, }; \ No newline at end of file