From a4c89f61714b63c803203084fa609086039a42ad Mon Sep 17 00:00:00 2001 From: "David R. Myers" Date: Sun, 7 Jul 2024 16:59:14 -0400 Subject: [PATCH] Improve list styles with independent config --- README.md | 3 +- examples/demo.ts | 2 +- src/editor/extensions/lists.ts | 441 ++++++++++++++++++++++++++---- src/extensions.ts | 16 +- src/store.ts | 1 + src/ui/components/root/styles.css | 11 - test/unit/src/index.test.ts | 84 ++++++ types/ink.ts | 10 + 8 files changed, 501 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index ebeb2d0..36fe074 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The `ink-mde/svelte` subpath exports a Svelte component. These are the default options, and any of them can be overridden when initializing (or reconfiguring) an instance of `ink-mde`. ```ts -// ./src/store.ts#L12-L65 +// ./src/store.ts#L12-L66 const options = { doc: '', files: { @@ -257,6 +257,7 @@ const options = { tab: true, shiftTab: true, }, + lists: false, placeholder: '', plugins: [ katex(), diff --git a/examples/demo.ts b/examples/demo.ts index f0686e4..ddd23b8 100644 --- a/examples/demo.ts +++ b/examples/demo.ts @@ -41,11 +41,11 @@ window.ink = ink(document.getElementById('app')!, { }, interface: { images: true, - lists: true, readonly: false, spellcheck: true, toolbar: true, }, + lists: true, placeholder: 'Start typing...', readability: true, toolbar: { diff --git a/src/editor/extensions/lists.ts b/src/editor/extensions/lists.ts index aa43923..d3e7f6b 100644 --- a/src/editor/extensions/lists.ts +++ b/src/editor/extensions/lists.ts @@ -1,23 +1,45 @@ import { syntaxTree } from '@codemirror/language' -import { RangeSet, StateField } from '@codemirror/state' +import { StateField } from '@codemirror/state' import type { EditorState, Extension, Range } from '@codemirror/state' import { Decoration, EditorView, ViewPlugin } from '@codemirror/view' import type { DecorationSet } from '@codemirror/view' +import type { SyntaxNodeRef } from '@lezer/common' import { buildWidget } from '/lib/codemirror-kit' -const dotWidget = () => buildWidget({ - eq: () => { - return false - }, - toDOM: () => { - const span = document.createElement('span') +const tabSize = 2 - span.innerHTML = '•' - span.setAttribute('aria-hidden', 'true') +const spacerWidget = () => { + return buildWidget({ + toDOM: () => { + const spacer = document.createElement('span') - return span - }, -}) + spacer.className = 'ink-mde-indent' + spacer.style.width = `2rem` + spacer.style.textDecoration = 'none' + spacer.style.display = 'inline-flex' + + const spacerLine = document.createElement('span') + + spacerLine.className = 'ink-mde-indent-marker' + spacerLine.innerHTML = ' ' + + spacer.appendChild(spacerLine) + + return spacer + }, + }) +} + +const createWrapper = () => { + const wrapper = document.createElement('label') + + wrapper.setAttribute('aria-hidden', 'true') + wrapper.setAttribute('tabindex', '-1') + wrapper.className = 'ink-mde-list-marker' + wrapper.style.minWidth = '2rem' + + return wrapper +} const taskWidget = (isChecked: boolean) => buildWidget({ eq: (other) => { @@ -26,90 +48,345 @@ const taskWidget = (isChecked: boolean) => buildWidget({ ignoreEvent: () => false, isChecked, toDOM: () => { + const wrapper = createWrapper() const input = document.createElement('input') input.setAttribute('aria-hidden', 'true') - input.className = 'ink-mde-task-toggle' + input.setAttribute('tabindex', '-1') + input.className = 'ink-mde-task-marker' input.type = 'checkbox' input.checked = isChecked - return input + wrapper.classList.add('ink-mde-task') + + wrapper.appendChild(input) + + return wrapper }, }) -const hasOverlap = (x1: number, x2: number, y1: number, y2: number) => { - return Math.max(x1, y1) <= Math.min(x2, y2) -} +const dotWidget = () => { + return buildWidget({ + toDOM: () => { + const wrapper = createWrapper() + + wrapper.setAttribute('inert', 'true') + wrapper.innerHTML = '•' -const isCursorInRange = (state: EditorState, from: number, to: number) => { - return state.selection.ranges.some((range) => { - return hasOverlap(from, to, range.from, range.to) + return wrapper + }, }) } -const toggleTask = (view: EditorView, position: number) => { - const before = view.state.sliceDoc(position + 2, position + 5) +const numberWidget = (marker: string) => { + return buildWidget({ + toDOM: () => { + const wrapper = createWrapper() + const content = document.createElement('span') + + wrapper.setAttribute('inert', 'true') - view.dispatch({ - changes: { - from: position + 2, - to: position + 5, - insert: before === '[ ]' ? '[x]' : '[ ]', + wrapper.appendChild(content) + + content.setAttribute('aria-hidden', 'true') + content.setAttribute('tabindex', '-1') + content.className = 'ink-mde-number-marker' + content.innerHTML = `${marker}` + + return wrapper }, }) +} + +const getVals = (state: EditorState, { from, to, type }: SyntaxNodeRef) => { + // Todo: Determine whether to skip blockquote or not. + if (type.name === 'Blockquote') { + return false + } + + if (type.name !== 'ListMark') { + return + } + + const line = state.doc.lineAt(from) + const lineStart = line.from + const marker = state.sliceDoc(from, to) + const markerStart = from + const markerEnd = to + const markerHasTrailingSpace = state.sliceDoc(markerEnd, markerEnd + 1) === ' ' + const indentation = markerStart - lineStart + + if (!markerHasTrailingSpace) { + return + } + + const indentLevel = Math.floor(indentation / tabSize) + const spacerDecorations = []>[] - return true + for (const index of Array(indentLevel).keys()) { + const from = lineStart + (index * tabSize) + const to = from + tabSize + + const spacerDec = Decoration.replace({ widget: spacerWidget() }).range(from, to) + + spacerDecorations.push(spacerDec) + } + + return { + indentLevel, + indentation, + lineStart, + marker, + markerEnd, + markerStart, + spacerDecorations, + } } -export const lists = (): Extension => { - const dotDecoration = () => Decoration.replace({ - widget: dotWidget(), +const bulletLists = (): Extension => { + const decorate = (state: EditorState): [DecorationSet, DecorationSet] => { + const atomicRanges = []>[] + const decorationRanges = []>[] + + syntaxTree(state).iterate({ + enter: (node) => { + const result = getVals(state, node) + + if (!result) { + return result + } + + const { indentLevel, lineStart, marker, markerEnd, markerStart, spacerDecorations } = result + + if (!['-', '*'].includes(marker)) { + return + } + + const lineDec = Decoration.line({ + attributes: { + class: 'ink-mde-list ink-mde-bullet-list', + style: `--indent-level: ${indentLevel}`, + }, + }).range(lineStart) + + decorationRanges.push(lineDec) + decorationRanges.push(...spacerDecorations) + atomicRanges.push(...spacerDecorations) + + const textStart = markerEnd + 1 + const dotDec = Decoration.replace({ + widget: dotWidget(), + }).range(markerStart, textStart) + + decorationRanges.push(dotDec) + atomicRanges.push(dotDec) + }, + }) + + return [Decoration.set(decorationRanges, true), Decoration.set(atomicRanges, true)] + } + + const stateField = StateField.define<[DecorationSet, DecorationSet]>({ + create(state) { + return decorate(state) + }, + update(_references, { state }) { + return decorate(state) + }, + provide(field) { + const result = [ + EditorView.decorations.of((view) => { + const [decorationRanges, _atomicRanges] = view.state.field(field) + + return decorationRanges + }), + EditorView.atomicRanges.of((view) => { + const [_decorationRanges, atomicRanges] = view.state.field(field) + + return atomicRanges + }), + ] + + return result + }, }) - const taskDecoration = (isChecked: boolean) => Decoration.replace({ - widget: taskWidget(isChecked), + return [ + stateField, + ] +} + +const numberLists = (): Extension => { + const decorate = (state: EditorState): [DecorationSet, DecorationSet] => { + const atomicRanges = []>[] + const decorationRanges = []>[] + + syntaxTree(state).iterate({ + enter: (node) => { + const result = getVals(state, node) + + if (!result) { + return result + } + + const { indentLevel, lineStart, marker, markerEnd, markerStart, spacerDecorations } = result + + if (['-', '*'].includes(marker)) { + return + } + + const lineDec = Decoration.line({ + attributes: { + class: 'ink-mde-list ink-mde-number-list', + style: `--indent-level: ${indentLevel}`, + }, + }).range(lineStart) + + decorationRanges.push(lineDec) + decorationRanges.push(...spacerDecorations) + atomicRanges.push(...spacerDecorations) + + const textStart = markerEnd + 1 + const dotDec = Decoration.replace({ + widget: numberWidget(marker), + }).range(markerStart, textStart) + + decorationRanges.push(dotDec) + atomicRanges.push(dotDec) + }, + }) + + return [Decoration.set(decorationRanges, true), Decoration.set(atomicRanges, true)] + } + + const stateField = StateField.define<[DecorationSet, DecorationSet]>({ + create(state) { + return decorate(state) + }, + update(_references, { state }) { + return decorate(state) + }, + provide(field) { + const result = [ + EditorView.decorations.of((view) => { + const [decorationRanges, _atomicRanges] = view.state.field(field) + + return decorationRanges + }), + EditorView.atomicRanges.of((view) => { + const [_decorationRanges, atomicRanges] = view.state.field(field) + + return atomicRanges + }), + ] + + return result + }, }) - const decorate = (state: EditorState) => { - const widgets: Range[] = [] + return [ + stateField, + ] +} + +const taskLists = (): Extension => { + const decorate = (state: EditorState): [DecorationSet, DecorationSet] => { + const atomicRanges = []>[] + const decorationRanges = []>[] syntaxTree(state).iterate({ - enter: ({ type, from, to }) => { - if (type.name === 'ListMark' && !isCursorInRange(state, from, to)) { - const task = state.sliceDoc(to + 1, to + 4) + enter: (node) => { + const result = getVals(state, node) - if (!['[ ]', '[x]'].includes(task)) { - const marker = state.sliceDoc(from, to) + if (!result) { + return result + } - if (['-', '*'].includes(marker)) { - widgets.push(dotDecoration().range(from, to)) - } - } + const { indentLevel, lineStart, marker, markerEnd, markerStart, spacerDecorations } = result + + if (!['-', '*'].includes(marker)) { + return } - if (type.name === 'TaskMarker' && !isCursorInRange(state, from - 2, to)) { - const task = state.sliceDoc(from, to) + const taskStart = markerEnd + 1 + const taskEnd = taskStart + 3 + const task = state.sliceDoc(taskStart, taskEnd) - widgets.push(taskDecoration(task === '[x]').range(from - 2, to)) + if (!['[ ]', '[x]'].includes(task)) { + return } + + const textStart = taskEnd + 1 + const taskHasTrailingSpace = state.sliceDoc(taskEnd, textStart) === ' ' + + if (!taskHasTrailingSpace) { + return + } + + const isChecked = task === '[x]' + + const lineDec = Decoration.line({ + attributes: { + class: `ink-mde-list ink-mde-task-list ${isChecked ? 'ink-mde-task-checked' : 'ink-mde-task-unchecked'}`, + style: `--indent-level: ${indentLevel}`, + }, + }).range(lineStart) + + decorationRanges.push(lineDec) + decorationRanges.push(...spacerDecorations) + atomicRanges.push(...spacerDecorations) + + const taskDec = Decoration.replace({ + widget: taskWidget(isChecked), + }).range(markerStart, textStart) + + decorationRanges.push(taskDec) + atomicRanges.push(taskDec) }, }) - return widgets.length > 0 ? RangeSet.of(widgets) : Decoration.none + return [Decoration.set(decorationRanges, true), Decoration.set(atomicRanges, true)] } const viewPlugin = ViewPlugin.define(() => ({}), { eventHandlers: { mousedown: (event, view) => { const target = event.target as HTMLElement + const realTarget = target.closest('.ink-mde-list-marker')?.querySelector('.ink-mde-task-marker') - if (target?.nodeName === 'INPUT' && target.classList.contains('ink-mde-task-toggle')) { - return toggleTask(view, view.posAtDOM(target)) + if (realTarget) { + const position = view.posAtDOM(realTarget) + const from = position - 4 + const to = position - 1 + const before = view.state.sliceDoc(from, to) + + if (before === '[ ]') { + view.dispatch({ + changes: { + from, + to, + insert: '[x]', + }, + }) + } + + if (before === '[x]') { + view.dispatch({ + changes: { + from, + to, + insert: '[ ]', + }, + }) + } + + return true } }, }, }) - const stateField = StateField.define({ + + const stateField = StateField.define<[DecorationSet, DecorationSet]>({ create(state) { return decorate(state) }, @@ -117,7 +394,20 @@ export const lists = (): Extension => { return decorate(state) }, provide(field) { - return EditorView.decorations.from(field) + const result = [ + EditorView.decorations.of((view) => { + const [decorationRanges, _atomicRanges] = view.state.field(field) + + return decorationRanges + }), + EditorView.atomicRanges.of((view) => { + const [_decorationRanges, atomicRanges] = view.state.field(field) + + return atomicRanges + }), + ] + + return result }, }) @@ -126,3 +416,50 @@ export const lists = (): Extension => { stateField, ] } + +export const lists = (config: { task: boolean, bullet: boolean, number: boolean }): Extension => { + return [ + config.task ? taskLists() : [], + config.bullet ? bulletLists() : [], + config.number ? numberLists() : [], + EditorView.theme({ + ':where(.ink-mde-indent)': { + display: 'inline-flex', + justifyContent: 'center', + }, + ':where(.ink-mde-indent-marker)': { + borderLeft: '1px solid var(--ink-internal-syntax-processing-instruction-color)', + bottom: '0', + overflow: 'hidden', + position: 'absolute', + top: '0', + width: '0', + }, + ':where(.ink-mde-list)': { + paddingLeft: 'calc(var(--indent-level) * 2rem + 2rem) !important', + position: 'relative', + textIndent: 'calc((var(--indent-level) * 2rem + 2rem) * -1)', + }, + ':where(.ink-mde-list *)': { + textIndent: '0', + }, + ':where(.ink-mde-list-marker)': { + alignItems: 'center', + color: 'var(--ink-internal-syntax-processing-instruction-color)', + display: 'inline-flex', + justifyContent: 'center', + minWidth: '2rem', + }, + ':where(.ink-mde-task-marker)': { + cursor: 'pointer', + margin: '0', + scale: '1.2', + transformOrigin: 'center center', + }, + ':where(.ink-mde-task-list.ink-mde-task-checked)': { + textDecoration: 'line-through', + textDecorationColor: 'var(--ink-internal-syntax-processing-instruction-color)', + }, + }), + ] +} diff --git a/src/extensions.ts b/src/extensions.ts index b918496..b8b1d29 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -115,10 +115,22 @@ export const lazyResolvers: InkInternal.LazyExtensionResolvers = [ return compartment.reconfigure([]) }, async ([state]: InkInternal.Store, compartment: InkInternal.Vendor.Compartment) => { - if (state().options.interface.lists) { + const { options } = state() + + if (options.lists || options.interface.lists) { const { lists } = await import('./editor/extensions/lists') - return compartment.reconfigure(lists()) + let bullet = true + let number = true + let task = true + + if (typeof options.lists === 'object') { + bullet = typeof options.lists.bullet === 'undefined' ? false : options.lists.bullet + number = typeof options.lists.number === 'undefined' ? false : options.lists.number + task = typeof options.lists.task === 'undefined' ? false : options.lists.task + } + + return compartment.reconfigure(lists({ bullet, number, task })) } return compartment.reconfigure([]) diff --git a/src/store.ts b/src/store.ts index 727a046..4e3b655 100644 --- a/src/store.ts +++ b/src/store.ts @@ -38,6 +38,7 @@ export const blankState = (): InkInternal.StateResolved => { tab: true, shiftTab: true, }, + lists: false, placeholder: '', plugins: [ katex(), diff --git a/src/ui/components/root/styles.css b/src/ui/components/root/styles.css index a2dcda8..ec9caad 100644 --- a/src/ui/components/root/styles.css +++ b/src/ui/components/root/styles.css @@ -118,17 +118,6 @@ width: 100%; } -.ink-mde .ink-mde-task-toggle { - cursor: pointer; - height: 1rem; - line-height: 2em; - margin: 0 0.25rem 0 0; - position: relative; - top: -1px; - vertical-align: middle; - width: 1rem; -} - .ink-mde .cm-editor { display: flex; flex-direction: column; diff --git a/test/unit/src/index.test.ts b/test/unit/src/index.test.ts index 8d19ab0..54bf532 100644 --- a/test/unit/src/index.test.ts +++ b/test/unit/src/index.test.ts @@ -52,6 +52,90 @@ describe('ink', () => { }) }) + describe('lists', () => { + const doc = `- A basic list\n- another item\n - and another\n\n1. A numbered list\n 2. another item\n3. and another\n\n- [ ] A task list\n - [x] another item\n- [ ] and another` + + it('disables lists by default', async () => { + const target = document.createElement('div') + + await ink(target, { + doc, + }) + + expect(target.querySelector('.ink-mde-indent')).toBeNull() + expect(target.querySelector('.ink-mde-list')).toBeNull() + expect(target.querySelector('.ink-mde-bullet-list')).toBeNull() + expect(target.querySelector('.ink-mde-number-list')).toBeNull() + expect(target.querySelector('.ink-mde-task-list')).toBeNull() + }) + + it('enables only bullet lists when configured', async () => { + const target = document.createElement('div') + + await ink(target, { + doc, + lists: { + bullet: true, + }, + }) + + expect(target.querySelector('.ink-mde-indent')).toBeDefined() + expect(target.querySelector('.ink-mde-list')).toBeDefined() + expect(target.querySelector('.ink-mde-bullet-list')).toBeDefined() + expect(target.querySelector('.ink-mde-number-list')).toBeNull() + expect(target.querySelector('.ink-mde-task-list')).toBeNull() + }) + + it('enables only number lists when configured', async () => { + const target = document.createElement('div') + + await ink(target, { + doc, + lists: { + number: true, + }, + }) + + expect(target.querySelector('.ink-mde-indent')).toBeDefined() + expect(target.querySelector('.ink-mde-list')).toBeDefined() + expect(target.querySelector('.ink-mde-bullet-list')).toBeNull() + expect(target.querySelector('.ink-mde-number-list')).toBeDefined() + expect(target.querySelector('.ink-mde-task-list')).toBeNull() + }) + + it('enables only task lists when configured', async () => { + const target = document.createElement('div') + + await ink(target, { + doc, + lists: { + task: true, + }, + }) + + expect(target.querySelector('.ink-mde-indent')).toBeDefined() + expect(target.querySelector('.ink-mde-list')).toBeDefined() + expect(target.querySelector('.ink-mde-bullet-list')).toBeNull() + expect(target.querySelector('.ink-mde-number-list')).toBeNull() + expect(target.querySelector('.ink-mde-task-list')).toBeDefined() + }) + + it('enables all lists when configured', async () => { + const target = document.createElement('div') + + await ink(target, { + doc, + lists: true, + }) + + expect(target.querySelector('.ink-mde-indent')).toBeDefined() + expect(target.querySelector('.ink-mde-list')).toBeDefined() + expect(target.querySelector('.ink-mde-bullet-list')).toBeDefined() + expect(target.querySelector('.ink-mde-number-list')).toBeDefined() + expect(target.querySelector('.ink-mde-task-list')).toBeDefined() + }) + }) + describe('wrap', () => { it('injects the editor after the textarea', () => { const form = document.createElement('form') diff --git a/types/ink.ts b/types/ink.ts index a8fbcd4..a8d3b0e 100644 --- a/types/ink.ts +++ b/types/ink.ts @@ -91,6 +91,11 @@ export interface Options { shiftTab?: boolean, tab?: boolean, }, + lists?: boolean | { + bullet?: boolean, + number?: boolean, + task?: boolean, + }, placeholder?: string, plugins?: Options.RecursivePlugin[], readability?: boolean, @@ -111,6 +116,11 @@ export interface OptionsResolved { shiftTab: boolean, tab: boolean, }, + lists: boolean | { + bullet: boolean, + number: boolean, + task: boolean, + }, placeholder: string, plugins: Options.RecursivePlugin[], readability: boolean,