Skip to content

Commit

Permalink
feat: component
Browse files Browse the repository at this point in the history
  • Loading branch information
r74tech committed May 27, 2023
1 parent 5293810 commit 842835b
Show file tree
Hide file tree
Showing 86 changed files with 15,500 additions and 2 deletions.
3 changes: 3 additions & 0 deletions modules/codemirror/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @wikijump/codemirror

A package containing consolidated exports for CodeMirror.
32 changes: 32 additions & 0 deletions modules/codemirror/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
205 changes: 205 additions & 0 deletions modules/codemirror/src/editor-field.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
/** 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<T>) => 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<T> {
/**
* 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<EditorView, Writable<T>>()

/** @param opts - Configuration for this field. A default state is required. */
constructor(opts: EditorFieldOpts<T>) {
this.effect = StateEffect.define<T>()

this.field = StateField.define<T>({
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<T> {
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)
}
}
}
}
12 changes: 12 additions & 0 deletions modules/codemirror/src/gutters.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>({
default: true,
reconfigure: state => (state ? [lineNumbers(), foldGutter()] : null)
})
62 changes: 62 additions & 0 deletions modules/codemirror/src/indent-hack.ts
Original file line number Diff line number Diff line change
@@ -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<Line>()
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<Decoration>()
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()
}
9 changes: 9 additions & 0 deletions modules/codemirror/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
21 changes: 21 additions & 0 deletions modules/codemirror/src/languages.ts
Original file line number Diff line number Diff line change
@@ -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<LanguageDescription>()

/** 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)
Loading

0 comments on commit 842835b

Please sign in to comment.