diff --git a/apps/www/src/content/docs/components/editor/demo.ts b/apps/www/src/content/docs/components/editor/demo.ts new file mode 100644 index 000000000..a2101eb38 --- /dev/null +++ b/apps/www/src/content/docs/components/editor/demo.ts @@ -0,0 +1,366 @@ +'use client'; + +import type { ComponentPropsType } from '@/components/demo/types'; +import { getPropsString } from '@/lib/utils'; + +const frame = + "{{ border: '0.5px solid var(--rs-color-border-base-primary)', borderRadius: 'var(--rs-radius-3)', padding: 'var(--rs-space-4)', minHeight: 120 }}"; + +const sampleDoc = `{ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Release notes' }] + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Select this text to format it, type ' }, + { type: 'text', marks: [{ type: 'code' }], text: '/' }, + { type: 'text', text: ' for commands, or ' }, + { type: 'text', marks: [{ type: 'code' }], text: '@' }, + { type: 'text', text: ' to mention someone.' } + ] + } + ] +}`; + +const people = `[ + { id: 'u1', label: 'Maya Chen', type: 'user' }, + { id: 'u2', label: 'Arjun Rao', type: 'user' }, + { id: 'u3', label: 'Dana Whitfield', type: 'user' } +]`; + +export const preview = { + type: 'code', + code: `function EditorPreview() { + const people = ${people}; + + return ( +
abc
Docs
x
'); + }); +}); + +describe('editorToText', () => { + it('separates blocks with a blank line and reads mentions as @label', () => { + const value = doc( + { type: 'heading', attrs: { level: 1 }, content: [text('Title')] }, + paragraph( + text('Ask '), + { + type: 'mention', + attrs: { id: 'u1', label: 'Maya', type: 'user', trigger: '@' } + }, + { type: 'hardBreak' }, + text('now') + ) + ); + expect(editorToText(value)).toBe('Title\n\nAsk @Maya\nnow'); + expect(docToText(docFromJSON(buildSchema(), value)).mentions).toEqual([ + { + id: 'u1', + label: 'Maya', + type: 'user', + trigger: '@', + start: 11, + end: 16 + } + ]); + }); +}); + +describe('docFromJSON', () => { + it('unwraps nodes the schema does not have and wraps misplaced ones', () => { + expect( + docFromJSON( + buildSchema(['bold']), + doc( + { + type: 'bulletList', + content: [ + { type: 'listItem', content: [paragraph(text('Buy milk'))] }, + { type: 'listItem', content: [paragraph(text('Call Bob'))] } + ] + }, + paragraph(text('x', [{ type: 'italic' }, { type: 'bold' }])) + ) + ).toJSON() + ).toEqual( + doc( + paragraph(text('Buy milk')), + paragraph(text('Call Bob')), + paragraph(text('x', [{ type: 'bold' }])) + ) + ); + expect( + docFromJSON( + buildSchema(), + doc(text('Hello'), { + type: 'listItem', + content: [paragraph(text('a'))] + }) + ).toJSON() + ).toEqual( + doc(paragraph(text('Hello')), { + type: 'bulletList', + content: [{ type: 'listItem', content: [paragraph(text('a'))] }] + }) + ); + }); + + it('repairs invalid content and never throws', () => { + const load = (value: unknown) => + docFromJSON(buildSchema(), value as EditorJSON).toJSON(); + expect( + load( + doc({ + type: 'codeBlock', + content: [ + text('a', [{ type: 'bold' }]), + { type: 'hardBreak' }, + text('b') + ] + }) + ) + ).toEqual( + doc({ + type: 'codeBlock', + attrs: { language: null }, + content: [text('a\nb')] + }) + ); + expect( + load( + doc({ type: 'heading', attrs: { level: 2.4 }, content: [text('h')] }) + ) + ).toEqual( + doc({ type: 'heading', attrs: { level: 2 }, content: [text('h')] }) + ); + expect( + load(doc(paragraph({ type: 'mention', attrs: { label: 'Maya' } }))) + ).toEqual(doc(paragraph(text('@Maya')))); + expect(load({ type: 'paragraph' })).toEqual(doc(paragraph())); + expect(load({ type: 'doc', content: [null] })).toEqual(doc(paragraph())); + expect(load({ type: 'doc', content: 'text' })).toEqual(doc(paragraph())); + }); +}); + +describe('commands', () => { + it('sets a link after Select All', () => { + const docNode = docFromJSON( + buildSchema(), + doc(paragraph(text('a')), paragraph(text('b'))) + ); + const state = EditorState.create({ + doc: docNode, + selection: new AllSelection(docNode) + }); + expect(apply(state, setLink('a.b'))).toEqual({ + ran: true, + json: doc( + paragraph(text('a', [link('https://a.b')])), + paragraph(text('b', [link('https://a.b')])) + ) + }); + }); + + it('keeps line breaks when a paragraph becomes a code block and back', () => { + const stateOf = (json: EditorJSON) => { + const docNode = docFromJSON(buildSchema(), json); + return EditorState.create({ + doc: docNode, + selection: TextSelection.create(docNode, 1) + }); + }; + const lines = doc(paragraph(text('a'), { type: 'hardBreak' }, text('b'))); + const code = apply(stateOf(lines), toggleBlock('codeBlock')); + expect(code.json).toEqual( + doc({ + type: 'codeBlock', + attrs: { language: null }, + content: [text('a\nb')] + }) + ); + expect(apply(stateOf(code.json), toggleBlock('codeBlock')).json).toEqual( + lines + ); + }); +}); + +describe('links', () => { + it('allows web, mail and relative links, and adds a scheme to bare hosts', () => { + for (const href of ['https://a.b', 'mailto:a@b.c', '/docs']) { + expect(isSafeHref(href), href).toBe(true); + } + for (const href of [ + 'javascript:alert(1)', + ' java\tscript:alert(1)', + 'data:text/html,x', + 'javascript:alert(1)', + 'javascript:alert(1)', + 'javascript:alert(1)' + ]) { + expect(isSafeHref(href), href).toBe(false); + } + expect(normalizeHref('raystack.org')).toBe('https://raystack.org'); + expect(normalizeHref('localhost:3000')).toBe('https://localhost:3000'); + expect(normalizeHref('a@b.co')).toBe('mailto:a@b.co'); + expect(normalizeHref('#top')).toBe('#top'); + }); +}); diff --git a/packages/raystack/components/editor/__tests__/data-slots.test.tsx b/packages/raystack/components/editor/__tests__/data-slots.test.tsx new file mode 100644 index 000000000..21cbdf1d6 --- /dev/null +++ b/packages/raystack/components/editor/__tests__/data-slots.test.tsx @@ -0,0 +1,73 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { expectSlots, getSlot } from '~/test-utils/data-slots'; +import { Editor } from '../editor'; +import type { EditorApi } from '../editor-types'; +import { contentOf, doc, flush, p, paste, select } from './test-utils'; + +describe('Editor data-slot contract', () => { + it('renders the root, content and fixed toolbar slots', async () => { + const { container } = render( +/h1
'); + }); + + it('runs a custom item with the editor api and shows its description', async () => { + const run = vi.fn((editor: EditorApi) => { + editor.commands.insertText('today'); + }); + const items: EditorSlashItem[] = [ + { id: 'date', label: 'Date', description: 'Insert a dated line', run }, + { id: 'other', label: 'Other', run: () => undefined } + ]; + const { content, api } = setup(Hello
'); + expect(bold).toHaveAttribute('aria-pressed', 'true'); + }); + + it('sets a heading from the menu', async () => { + const { api, view } = setup(); + select(view(), 1, 1); + const trigger = screen.getByRole('button', { name: 'Text style' }); + fireEvent.click(trigger); + await flush(); + // The same element stays the trigger, so the menu keeps its anchor. + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect( + await screen.findByRole('menuitemradio', { name: /Text/ }) + ).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(screen.getByRole('menuitemradio', { name: /Heading 2/ })); + await flush(); + expect(api().getHTML()).toBe('Hello
'); + expect('getMarkdown' in details).toBe(false); + }); + + it('applies a controlled value with a new history and does not report it', () => { + const actionsRef = createRefHello
'); + act(() => { + on.api().commands.undo(); + }); + pressKey(on.content, 'y', { ctrlKey: true }); + expect(on.api().isActive('bold')).toBe(true); + on.unmount(); + + const off = setup({ + defaultValue: doc(p('Hello')), + shortcuts: { bold: false, redo: false } + }); + select(off.view(), 1, 6); + pressKey(off.content, 'b', { ctrlKey: true }); + expect(off.api().isActive('bold')).toBe(false); + act(() => { + off.api().commands.toggleMark('bold'); + off.api().commands.undo(); + }); + pressKey(off.content, 'y', { ctrlKey: true }); + expect(off.api().isActive('bold')).toBe(false); + }); + + it('applies input rules, and waits for a space after ***', () => { + const cases = [ + ['# ', ''], + ['- ', '***bold
'], + ['**bold**', 'bold
'] + ]; + for (const [input, html] of cases) { + const { view, api, unmount } = setup(); + typeText(view(), input); + expect(api().getHTML(), input).toBe(html); + unmount(); + } + }); + + it('links the selection when a safe URL is pasted over it', () => { + const { view, api, content } = setup({ defaultValue: doc(p('Apsara')) }); + select(view(), 1, 7); + paste(content, 'javascript:alert(1)'); + expect(api().getHTML()).toBe('javascript:alert(1)
'); + select(view(), 1, 20); + paste(content, 'https://raystack.org'); + expect(api().getHTML()).toBe( + '' + ); + }); + + it('follows readOnly and disabled when they change after mount', () => { + const tasks = doc({ + type: 'taskList', + content: [ + { type: 'taskItem', attrs: { checked: true }, content: [p('Docs')] } + ] + }); + const { container, rerender } = render( +Item
'); + expect(api().commands.toggleList('bulletList')).toBe(true); + expect(api().getHTML()).toBe('Item
ab!
'); + }); +}); diff --git a/packages/raystack/components/editor/__tests__/markdown.test.tsx b/packages/raystack/components/editor/__tests__/markdown.test.tsx new file mode 100644 index 000000000..43d5fdd7a --- /dev/null +++ b/packages/raystack/components/editor/__tests__/markdown.test.tsx @@ -0,0 +1,292 @@ +import { render } from '@testing-library/react'; +import { createRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { docFromJSON, type EditorJSON } from '../core/json'; +import { buildSchema } from '../core/schema'; +import { Editor } from '../editor'; +import type { EditorApi } from '../editor-types'; +import { MarkdownAdapter } from '../markdown'; +import { contentOf, copy, paste, pressKey, select } from './test-utils'; + +const t = (text: string, marks?: EditorJSON['marks']): EditorJSON => ({ + type: 'text', + text, + ...(marks ? { marks } : {}) +}); +const para = (...content: EditorJSON[]): EditorJSON => ({ + type: 'paragraph', + ...(content.length ? { content } : {}) +}); +const doc = (...content: EditorJSON[]): EditorJSON => ({ + type: 'doc', + content +}); +const link = (href: string) => [{ type: 'link', attrs: { href } }]; +const mention: EditorJSON = { + type: 'mention', + attrs: { id: 'u1', label: 'Maya', type: 'user', trigger: '@' } +}; +const bullets = (...items: string[]): EditorJSON => ({ + type: 'bulletList', + content: items.map(item => ({ type: 'listItem', content: [para(t(item))] })) +}); +const tasks = (...items: string[]): EditorJSON => ({ + type: 'taskList', + content: items.map(item => ({ + type: 'taskItem', + attrs: { checked: false }, + content: [para(...(item ? [t(item)] : []))] + })) +}); + +/** Loads through the schema, so defaults such as `language: null` are filled in. */ +function normalize(value: EditorJSON): EditorJSON { + return docFromJSON(buildSchema(), value).toJSON() as EditorJSON; +} + +function roundTrip(value: EditorJSON): EditorJSON { + return normalize(MarkdownAdapter.toEditor(MarkdownAdapter.fromEditor(value))); +} + +const EVERYTHING = doc( + { type: 'heading', attrs: { level: 1 }, content: [t('Release notes')] }, + para( + t('Plain '), + t('bold', [{ type: 'bold' }]), + t(' '), + t('italic', [{ type: 'italic' }]), + t(' '), + t('strike', [{ type: 'strike' }]), + t(' '), + t('under', [{ type: 'underline' }]), + t(' '), + t('code', [{ type: 'code' }]), + t(' '), + t('link', link('https://raystack.org')), + t(' and '), + { + type: 'mention', + attrs: { id: 'u_42', label: 'Maya Chen', type: 'user', trigger: '@' } + }, + t(' done.') + ), + para(t('line one'), { type: 'hardBreak' }, t('line two')), + { type: 'blockquote', content: [para(t('Quoted'))] }, + { + type: 'codeBlock', + attrs: { language: 'ts' }, + content: [t('const a = `x`;\nconst b = 2;')] + }, + { type: 'horizontalRule' }, + { + type: 'bulletList', + content: [ + { type: 'listItem', content: [para(t('One')), bullets('Nested')] }, + { type: 'listItem', content: [para(t('Two'))] } + ] + }, + { + type: 'orderedList', + attrs: { start: 3 }, + content: [{ type: 'listItem', content: [para(t('Three'))] }] + }, + { + type: 'taskList', + content: [ + { type: 'taskItem', attrs: { checked: true }, content: [para(t('Done'))] } + ] + }, + para(t('Special *chars* _here_ [x] # not a heading')) +); + +describe('MarkdownAdapter', () => { + it('round-trips every node and mark', () => { + expect(roundTrip(EVERYTHING)).toEqual(normalize(EVERYTHING)); + }); + + it('writes readable Markdown', () => { + expect( + MarkdownAdapter.fromEditor( + doc( + { type: 'heading', attrs: { level: 2 }, content: [t('Title')] }, + para(t('Ship the '), t('fix', [{ type: 'bold' }]), t('.')), + tasks('Docs') + ) + ) + ).toBe('## Title\n\nShip the **fix**.\n\n- [ ] Docs'); + }); + + it('round-trips content that plain Markdown would misread', () => { + const cases: Recordone
two
**x**');
+ });
+
+ // An empty paragraph has no Markdown, so reloading the string would drop it.
+ it('keeps the doc when a controlled string is what the doc converts to', () => {
+ const actionsRef = createRef