From 87e60130ccf91a19bf5f79940277ac272f4184e3 Mon Sep 17 00:00:00 2001 From: sethburkart123 Date: Mon, 24 Jun 2024 19:44:44 +1000 Subject: [PATCH] feat: add experimental better editor --- package.json | 3 + src/SEQTA.ts | 9 +- src/css/injected.scss | 8 + .../components/textEditor/constants.ts | 11 + .../components/textEditor/interfaces.ts | 329 +++++++++++++++ .../components/textEditor/richTextResolver.ts | 396 ++++++++++++++++++ .../components/textEditor/sbFetch.ts | 192 +++++++++ .../components/textEditor/sbHelpers.ts | 101 +++++ src/interface/components/textEditor/schema.ts | 274 ++++++++++++ src/interface/main.tsx | 2 + src/interface/pages/Editor.css | 11 + src/interface/pages/Editor.tsx | 25 ++ src/seqta/ui/AddBetterSEQTAElements.ts | 6 +- src/seqta/ui/customMessageEditor.ts | 86 ++++ 14 files changed, 1447 insertions(+), 6 deletions(-) create mode 100644 src/interface/components/textEditor/constants.ts create mode 100644 src/interface/components/textEditor/interfaces.ts create mode 100644 src/interface/components/textEditor/richTextResolver.ts create mode 100644 src/interface/components/textEditor/sbFetch.ts create mode 100644 src/interface/components/textEditor/sbHelpers.ts create mode 100644 src/interface/components/textEditor/schema.ts create mode 100644 src/interface/pages/Editor.css create mode 100644 src/interface/pages/Editor.tsx create mode 100644 src/seqta/ui/customMessageEditor.ts diff --git a/package.json b/package.json index 7eaf4045..e92c806e 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "url": "^0.11.3" }, "dependencies": { + "@blocknote/core": "^0.14.1", + "@blocknote/mantine": "^0.14.1", + "@blocknote/react": "^0.14.1", "@codemirror/lang-less": "^6.0.2", "@heroicons/react": "^2.1.3", "@million/lint": "latest", diff --git a/src/SEQTA.ts b/src/SEQTA.ts index c7513dcd..171b035b 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -27,6 +27,7 @@ import { initializeSettingsState, settingsState } from './seqta/utils/listeners/ import { StorageChangeHandler } from './seqta/utils/listeners/StorageChanges' import { AddBetterSEQTAElements } from './seqta/ui/AddBetterSEQTAElements' import { eventManager } from './seqta/utils/listeners/EventManager' +import handleComposeMessage from './seqta/ui/customMessageEditor' declare global { interface Window { @@ -65,9 +66,6 @@ async function init() { if (settingsState.onoff) { enableCurrentTheme() - //console.log(await browser.storage.local.get()) - //settingsState.bksliderinput = '10' - //console.log(await browser.storage.local.get()) // TEMP FIX for bug! -> this is a hack to get the injected.css file to have HMR in development mode as this import system is currently broken with crxjs if (import.meta.env.MODE === 'development') { @@ -563,6 +561,11 @@ async function LoadPageElements(): Promise { className: 'timetablepage', }, handleTimetable); + eventManager.register('composeMessage', { + elementType: 'div', + customCheck: (element: Element) => element.querySelector('.coneqtMessage') !== null + }, handleComposeMessage); + await handleSublink(sublink); } diff --git a/src/css/injected.scss b/src/css/injected.scss index e06c77bd..1bf72d58 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -31,6 +31,14 @@ html { background-color 200ms ease-in-out, backdrop-filter 200ms ease-in-out; } +.extension-editor { + background: var(--background-primary); + border-radius: 16px; + border: none !important; + width: 100%; + height: 100%; + visibility: visible !important; +} #themeCreatorIframe { position: fixed; right: 0; diff --git a/src/interface/components/textEditor/constants.ts b/src/interface/components/textEditor/constants.ts new file mode 100644 index 00000000..caf4de7f --- /dev/null +++ b/src/interface/components/textEditor/constants.ts @@ -0,0 +1,11 @@ +const METHOD = { + GET: 'get', + DELETE: 'delete', + POST: 'post', + PUT: 'put', +} as const + +type ObjectValues = T[keyof T] +type Method = ObjectValues + +export default Method \ No newline at end of file diff --git a/src/interface/components/textEditor/interfaces.ts b/src/interface/components/textEditor/interfaces.ts new file mode 100644 index 00000000..b501908e --- /dev/null +++ b/src/interface/components/textEditor/interfaces.ts @@ -0,0 +1,329 @@ +import { ResponseFn } from './sbFetch' + +export interface ISbStoriesParams + extends Partial, + ISbMultipleStoriesData { + resolve_level?: number + _stopResolving?: boolean + by_slugs?: string + by_uuids?: string + by_uuids_ordered?: string + component?: string + content_type?: string + cv?: number + datasource?: string + dimension?: string + excluding_fields?: string + excluding_ids?: string + excluding_slugs?: string + fallback_lang?: string + filename?: string + filter_query?: any + first_published_at_gt?: string + first_published_at_lt?: string + from_release?: string + is_startpage?: boolean + language?: string + level?: number + page?: number + per_page?: number + published_at_gt?: string + published_at_lt?: string + resolve_assets?: number + resolve_links?: 'link' | 'url' | 'story' | '0' | '1' | 'link' + resolve_relations?: string | string[] + search_term?: string + size?: string + sort_by?: string + starts_with?: string + token?: string + version?: 'draft' | 'published' + with_tag?: string +} + +export interface ISbStoryParams { + resolve_level?: number + token?: string + find_by?: 'uuid' + version?: 'draft' | 'published' + resolve_links?: 'link' | 'url' | 'story' | '0' | '1' + resolve_relations?: string | string[] + cv?: number + from_release?: string + language?: string + fallback_lang?: string +} + +export interface ISbComponentType { + _uid?: string + component?: T + _editable?: string +} + +export interface ISbStoryData< + Content = ISbComponentType & { [index: string]: any }, +> extends ISbMultipleStoriesData { + alternates: ISbAlternateObject[] + breadcrumbs?: ISbLinkURLObject[] + content: Content + created_at: string + default_full_slug?: string + default_root?: string + disble_fe_editor?: boolean + first_published_at?: string + full_slug: string + group_id: string + id: number + imported_at?: string + is_folder?: boolean + is_startpage?: boolean + lang: string + last_author?: { + id: number + userid: string + } + meta_data: any + name: string + parent?: ISbStoryData + parent_id: number + path?: string + pinned?: '1' | boolean + position: number + published?: boolean + published_at: string | null + release_id?: number + slug: string + sort_by_date: string | null + tag_list: string[] + translated_slugs?: { + path: string + name: string | null + lang: ISbStoryData['lang'] + }[] + unpublished_changes?: boolean + updated_at?: string + uuid: string +} + +export interface ISbMultipleStoriesData { + by_ids?: string + by_uuids?: string + contain_component?: string + excluding_ids?: string + filter_query?: any + folder_only?: boolean + full_slug?: string + in_release?: string + in_trash?: boolean + is_published?: boolean + in_workflow_stages?: string + page?: number + pinned?: '1' | boolean + search?: string + sort_by?: string + starts_with?: string + story_only?: boolean + text_search?: string + with_parent?: number + with_slug?: string + with_tag?: string +} + +export interface ISbAlternateObject { + id: number + name: string + slug: string + published: boolean + full_slug: string + is_folder: boolean + parent_id: number +} + +export interface ISbLinkURLObject { + id: number + name: string + slug: string + full_slug: string + url: string + uuid: string +} + +export interface ISbStories { + data: { + cv: number + links: (ISbStoryData | ISbLinkURLObject)[] + rels: ISbStoryData[] + stories: ISbStoryData[] + } + perPage: number + total: number + headers: any +} + +export interface ISbStory { + data: { + cv: number + links: (ISbStoryData | ISbLinkURLObject)[] + rels: ISbStoryData[] + story: ISbStoryData + } + headers: any +} + +export interface IMemoryType extends ISbResult { + [key: string]: any +} + +export interface ICacheProvider { + get: (key: string) => Promise + set: (key: string, content: ISbResult) => Promise + getAll: () => Promise + flush: () => Promise +} + +export interface ISbCache { + type?: 'none' | 'memory' | 'custom' + clear?: 'auto' | 'manual' + custom?: ICacheProvider +} + +export interface ISbConfig { + accessToken?: string + oauthToken?: string + resolveNestedRelations?: boolean + cache?: ISbCache + responseInterceptor?: ResponseFn + fetch?: typeof fetch + timeout?: number + headers?: any + region?: string + maxRetries?: number + https?: boolean + rateLimit?: number + componentResolver?: (component: string, data: any) => void + richTextSchema?: ISbSchema + endpoint?: string +} + +export interface ISbResult { + data: any + perPage: number + total: number + headers: Headers +} + +export interface ISbResponse { + data: any + status: number + statusText: string +} + +export interface ISbError { + message?: string + status?: number + response?: ISbResponse +} + +export interface ISbNode extends Element { + content: object[] + attrs: { + anchor?: string + body?: Array> + href?: string + level?: number + linktype?: string + custom?: LinkCustomAttributes + [key: string]: any | undefined + } +} + +export type NodeSchema = { + (node: ISbNode): object +} + +export type MarkSchema = { + (node: ISbNode): object +} + +export interface ISbContentMangmntAPI< + Content = ISbComponentType & { [index: string]: any }, +> { + story: { + name: string + slug: string + content?: Content + default_root?: boolean + is_folder?: boolean + parent_id?: string + disble_fe_editor?: boolean + path?: string + is_startpage?: boolean + position?: number + first_published_at?: string + translated_slugs_attributes?: { + path: string + name: string | null + lang: ISbContentMangmntAPI['lang'] + }[] + } + force_update?: '1' | unknown + release_id?: number + publish?: '1' | unknown + lang?: string +} + +export interface ISbManagmentApiResult { + data: any + headers: any +} + +export interface ISbSchema { + nodes: any + marks: any +} + +export interface ISbRichtext { + content?: ISbRichtext[] + marks?: ISbRichtext[] + attrs?: any + text?: string + type: string +} + +export interface LinkCustomAttributes { + rel?: string + title?: string + [key: string]: any +} + +export interface ISbLink { + id?: number + slug?: string + name?: string + is_folder?: boolean + parent_id?: number + published?: boolean + position?: number + uuid?: string + is_startpage?: boolean +} + +export interface ISbLinks { + links?: { + [key: string]: ISbLink + } +} + +export type ThrottleFn = { + (...args: any): any +} + +export type AsyncFn = (...args: any) => [] | Promise + +export type ArrayFn = (...args: any) => void + +export type HtmlEscapes = { + [key: string]: string +} + +export interface ISbCustomFetch extends Omit {} \ No newline at end of file diff --git a/src/interface/components/textEditor/richTextResolver.ts b/src/interface/components/textEditor/richTextResolver.ts new file mode 100644 index 00000000..91f7a2f8 --- /dev/null +++ b/src/interface/components/textEditor/richTextResolver.ts @@ -0,0 +1,396 @@ +import defaultHtmlSerializer from './schema' +import { ISbSchema, ISbRichtext } from './interfaces' + +type HtmlEscapes = { + [key: string]: string +} + +type OptimizeImagesOptions = + | boolean + | { + class?: string + filters?: { + blur?: number + brightness?: number + fill?: string + format?: 'webp' | 'jpeg' | 'png' + grayscale?: boolean + quality?: number + rotate?: 90 | 180 | 270 + } + height?: number + loading?: 'lazy' | 'eager' + sizes?: string[] + srcset?: (number | [number, number])[] + width?: number + } + +type RenderOptions = { + optimizeImages?: OptimizeImagesOptions +} + +const escapeHTML = function (string: string) { + const htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + } as HtmlEscapes + + const reUnescapedHtml = /[&<>"']/g + const reHasUnescapedHtml = RegExp(reUnescapedHtml.source) + + return string && reHasUnescapedHtml.test(string) + ? string.replace(reUnescapedHtml, (chr) => htmlEscapes[chr]) + : string +} + +interface ISbTag extends Element { + [key: string]: any +} + +interface ISbNode { + [key: string]: ISbSchema | ((arg: ISbRichtext) => any) +} + +interface ISbFunction { + (...args: T): R +} + +class RichTextResolver { + private marks: ISbNode + private nodes: ISbNode + + public constructor(schema?: ISbSchema) { + if (!schema) { + schema = defaultHtmlSerializer as ISbSchema + } + + this.marks = schema.marks || [] + this.nodes = schema.nodes || [] + } + + public addNode(key: string, schema: ISbSchema | ISbFunction) { + this.nodes[key] = schema + } + + public addMark(key: string, schema: ISbSchema) { + this.marks[key] = schema + } + + public render( + data?: ISbRichtext, + options: RenderOptions = { optimizeImages: false } + ) { + if (data && data.content && Array.isArray(data.content)) { + let html = '' + + data.content.forEach((node: any) => { + html += this.renderNode(node) + }) + + if (options.optimizeImages) { + return this.optimizeImages(html, options.optimizeImages) + } + + return html + } + + console.warn( + `The render method must receive an Object with a "content" field. + The "content" field must be an array of nodes as the type ISbRichtext. + ISbRichtext: + content?: ISbRichtext[] + marks?: ISbRichtext[] + attrs?: any + text?: string + type: string + + Example: + { + content: [ + { + content: [ + { + text: 'Hello World', + type: 'text' + } + ], + type: 'paragraph' + } + ], + type: 'doc' + }` + ) + return '' + } + + private optimizeImages(html: string, options: OptimizeImagesOptions): string { + let w = 0 + let h = 0 + let imageAttributes = '' + let filters = '' + + if (typeof options !== 'boolean') { + if (typeof options.width === 'number' && options.width > 0) { + imageAttributes += `width="${options.width}" ` + w = options.width + } + + if (typeof options.height === 'number' && options.height > 0) { + imageAttributes += `height="${options.height}" ` + h = options.height + } + + if (options.loading === 'lazy' || options.loading === 'eager') { + imageAttributes += `loading="${options.loading}" ` + } + + if (typeof options.class === 'string' && options.class.length > 0) { + imageAttributes += `class="${options.class}" ` + } + + if (options.filters) { + if ( + typeof options.filters.blur === 'number' && + options.filters.blur >= 0 && + options.filters.blur <= 100 + ) { + filters += `:blur(${options.filters.blur})` + } + + if ( + typeof options.filters.brightness === 'number' && + options.filters.brightness >= -100 && + options.filters.brightness <= 100 + ) { + filters += `:brightness(${options.filters.brightness})` + } + + if ( + options.filters.fill && + (options.filters.fill.match(/[0-9A-Fa-f]{6}/g) || + options.filters.fill === 'transparent') + ) { + filters += `:fill(${options.filters.fill})` + } + + if ( + options.filters.format && + ['webp', 'png', 'jpeg'].includes(options.filters.format) + ) { + filters += `:format(${options.filters.format})` + } + + if ( + typeof options.filters.grayscale === 'boolean' && + options.filters.grayscale + ) { + filters += ':grayscale()' + } + + if ( + typeof options.filters.quality === 'number' && + options.filters.quality >= 0 && + options.filters.quality <= 100 + ) { + filters += `:quality(${options.filters.quality})` + } + + if ( + options.filters.rotate && + [90, 180, 270].includes(options.filters.rotate) + ) { + filters += `:rotate(${options.filters.rotate})` + } + + if (filters.length > 0) filters = '/filters' + filters + } + } + + if (imageAttributes.length > 0) { + html = html.replace(/ { + const url = value.match( + /a.storyblok.com\/f\/(\d+)\/([^.]+)\.(gif|jpg|jpeg|png|tif|tiff|bmp)/g + ) + + if (url && url.length > 0) { + const imageAttributes = { + srcset: options.srcset + ?.map((value) => { + if (typeof value === 'number') { + return `//${url}/m/${value}x0${filters} ${value}w` + } + + if (typeof value === 'object' && value.length === 2) { + let w = 0 + let h = 0 + if (typeof value[0] === 'number') w = value[0] + if (typeof value[1] === 'number') h = value[1] + return `//${url}/m/${w}x${h}${filters} ${w}w` + } + }) + .join(', '), + sizes: options.sizes?.map((size) => size).join(', '), + } + + let renderImageAttributes = '' + if (imageAttributes.srcset) { + renderImageAttributes += `srcset="${imageAttributes.srcset}" ` + } + if (imageAttributes.sizes) { + renderImageAttributes += `sizes="${imageAttributes.sizes}" ` + } + + return value.replace(/ { + const mark = this.getMatchingMark(m) + + if (mark && mark.tag !== '') { + html.push(this.renderOpeningTag(mark.tag)) + } + }) + } + + const node = this.getMatchingNode(item) + + if (node && node.tag) { + html.push(this.renderOpeningTag(node.tag)) + } + + if (item.content) { + item.content.forEach((content: any) => { + html.push(this.renderNode(content)) + }) + } else if (item.text) { + html.push(escapeHTML(item.text)) + } else if (node && node.singleTag) { + html.push(this.renderTag(node.singleTag, ' /')) + } else if (node && node.html) { + html.push(node.html) + } else if (item.type === 'emoji') { + html.push(this.renderEmoji(item)) + } + + if (node && node.tag) { + html.push(this.renderClosingTag(node.tag)) + } + + if (item.marks) { + item.marks + .slice(0) + .reverse() + .forEach((m) => { + const mark = this.getMatchingMark(m) + + if (mark && mark.tag !== '') { + html.push(this.renderClosingTag(mark.tag)) + } + }) + } + + return html.join('') + } + + private renderTag(tags: ISbTag[], ending: string) { + if (tags.constructor === String) { + return `<${tags}${ending}>` + } + + const all = tags.map((tag) => { + if (tag.constructor === String) { + return `<${tag}${ending}>` + } else { + let h = `<${tag.tag}` + if (tag.attrs) { + for (const key in tag.attrs) { + const value = tag.attrs[key] + if (value !== null) { + h += ` ${key}="${value}"` + } + } + } + + return `${h}${ending}>` + } + }) + return all.join('') + } + + private renderOpeningTag(tags: ISbTag[]) { + return this.renderTag(tags, '') + } + + private renderClosingTag(tags: ISbTag[]) { + if (tags.constructor === String) { + return `` + } + + const all = tags + .slice(0) + .reverse() + .map((tag) => { + if (tag.constructor === String) { + return `` + } else { + return `` + } + }) + + return all.join('') + } + + private getMatchingNode(item: ISbRichtext) { + const node = this.nodes[item.type] + if (typeof node === 'function') { + return node(item) + } + } + + private getMatchingMark(item: ISbRichtext) { + const node = this.marks[item.type] + if (typeof node === 'function') { + return node(item) + } + } + + private renderEmoji(item: ISbRichtext) { + if (item.attrs.emoji) { + return item.attrs.emoji + } + + const emojiImageContainer = [ + { + tag: 'img', + attrs: { + src: item.attrs.fallbackImage, + draggable: 'false', + loading: 'lazy', + align: 'absmiddle', + }, + }, + ] as unknown as ISbTag[] + + return this.renderTag(emojiImageContainer, ' /') + } +} + +export default RichTextResolver \ No newline at end of file diff --git a/src/interface/components/textEditor/sbFetch.ts b/src/interface/components/textEditor/sbFetch.ts new file mode 100644 index 00000000..dd67431f --- /dev/null +++ b/src/interface/components/textEditor/sbFetch.ts @@ -0,0 +1,192 @@ +import { SbHelpers } from './sbHelpers' + +import { + ISbResponse, + ISbError, + ISbStoriesParams, + ISbCustomFetch, +} from './interfaces' +import Method from './constants' + +export type ResponseFn = { + (arg?: ISbResponse | any): any +} + +interface ISbFetch { + baseURL: string + timeout?: number + headers: Headers + responseInterceptor?: ResponseFn + fetch?: typeof fetch +} + +class SbFetch { + private baseURL: string + private timeout?: number + private headers: Headers + private responseInterceptor?: ResponseFn + private fetch: typeof fetch + private ejectInterceptor?: boolean + private url: string + private parameters: ISbStoriesParams + private fetchOptions: ISbCustomFetch + + public constructor($c: ISbFetch) { + this.baseURL = $c.baseURL + this.headers = $c.headers || new Headers() + this.timeout = $c?.timeout ? $c.timeout * 1000 : 0 + this.responseInterceptor = $c.responseInterceptor + this.fetch = (...args: [any]) => + $c.fetch ? $c.fetch(...args) : fetch(...args) + this.ejectInterceptor = false + this.url = '' + this.parameters = {} as ISbStoriesParams + this.fetchOptions = {} + } + + /** + * + * @param url string + * @param params ISbStoriesParams + * @returns Promise + */ + public get(url: string, params: ISbStoriesParams) { + this.url = url + this.parameters = params + return this._methodHandler('get') + } + + public post(url: string, params: ISbStoriesParams) { + this.url = url + this.parameters = params + return this._methodHandler('post') + } + + public put(url: string, params: ISbStoriesParams) { + this.url = url + this.parameters = params + return this._methodHandler('put') + } + + public delete(url: string, params: ISbStoriesParams) { + this.url = url + this.parameters = params + return this._methodHandler('delete') + } + + private async _responseHandler(res: Response) { + const headers: string[] = [] + const response = { + data: {}, + headers: {}, + status: 0, + statusText: '', + } + + if (res.status !== 204) { + await res.json().then(($r) => { + response.data = $r + }) + } + + for (const pair of res.headers.entries()) { + headers[pair[0] as any] = pair[1] + } + + response.headers = { ...headers } + response.status = res.status + response.statusText = res.statusText + + return response + } + + private async _methodHandler( + method: Method + ): Promise { + let urlString = `${this.baseURL}${this.url}` + + let body = null + + if (method === 'get') { + const helper = new SbHelpers() + urlString = `${this.baseURL}${this.url}?${helper.stringify( + this.parameters + )}` + } else { + body = JSON.stringify(this.parameters) + } + + const url = new URL(urlString) + + const controller = new AbortController() + const { signal } = controller + + let timeout + + if (this.timeout) { + timeout = setTimeout(() => controller.abort(), this.timeout) + } + + try { + const fetchResponse = await this.fetch(`${url}`, { + method, + headers: this.headers, + body, + signal, + ...this.fetchOptions, + }) + + if (this.timeout) { + clearTimeout(timeout) + } + + const response = (await this._responseHandler( + fetchResponse + )) as ISbResponse + + if (this.responseInterceptor && !this.ejectInterceptor) { + return this._statusHandler(this.responseInterceptor(response)) + } else { + return this._statusHandler(response) + } + } catch (err: any) { + const error: ISbError = { + message: err, + } + return error + } + } + + public setFetchOptions(fetchOptions: ISbCustomFetch = {}) { + if (Object.keys(fetchOptions).length > 0 && 'method' in fetchOptions) { + delete fetchOptions.method + } + this.fetchOptions = { ...fetchOptions } + } + + public eject() { + this.ejectInterceptor = true + } + + private _statusHandler(res: ISbResponse): Promise { + const statusOk = /20[0-6]/g + + return new Promise((resolve, reject) => { + if (statusOk.test(`${res.status}`)) { + return resolve(res) + } + + const error: ISbError = { + message: res.statusText, + status: res.status, + response: Array.isArray(res.data) + ? res.data[0] + : res.data.error || res.data.slug, + } + + reject(error) + }) + } +} + +export default SbFetch \ No newline at end of file diff --git a/src/interface/components/textEditor/sbHelpers.ts b/src/interface/components/textEditor/sbHelpers.ts new file mode 100644 index 00000000..b2a8ef8c --- /dev/null +++ b/src/interface/components/textEditor/sbHelpers.ts @@ -0,0 +1,101 @@ +import { ISbStoriesParams, ISbResult, AsyncFn, HtmlEscapes } from './interfaces' +interface ISbParams extends ISbStoriesParams { + [key: string]: any +} + +type ArrayFn = (...args: any) => void + +type FlatMapFn = (...args: any) => [] | any + +type RangeFn = (...args: any) => [] + +export class SbHelpers { + public isCDNUrl = (url = '') => url.indexOf('/cdn/') > -1 + + public getOptionsPage = ( + options: ISbStoriesParams, + perPage = 25, + page = 1 + ) => { + return { + ...options, + per_page: perPage, + page, + } + } + + public delay = (ms: number) => new Promise((res) => setTimeout(res, ms)) + + public arrayFrom = (length = 0, func: ArrayFn) => [...Array(length)].map(func) + + public range = (start = 0, end = start): Array => { + const length = Math.abs(end - start) || 0 + const step = start < end ? 1 : -1 + return this.arrayFrom(length, (_, i: number) => i * step + start) + } + + public asyncMap = async (arr: RangeFn[], func: AsyncFn) => + Promise.all(arr.map(func)) + + public flatMap = (arr: ISbResult[] = [], func: FlatMapFn) => + arr.map(func).reduce((xs, ys) => [...xs, ...ys], []) + + /** + * @method stringify + * @param {Object} params + * @param {String} prefix + * @param {Boolean} isArray + * @return {String} Stringified object + */ + public stringify( + params: ISbParams, + prefix?: string, + isArray?: boolean + ): string { + const pairs = [] + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) { + continue + } + const value = params[key] + const enkey = isArray ? '' : encodeURIComponent(key) + let pair + if (typeof value === 'object') { + pair = this.stringify( + value, + prefix ? prefix + encodeURIComponent('[' + enkey + ']') : enkey, + Array.isArray(value) + ) + } else { + pair = + (prefix ? prefix + encodeURIComponent('[' + enkey + ']') : enkey) + + '=' + + encodeURIComponent(value) + } + pairs.push(pair) + } + return pairs.join('&') + } + + /** + * @method escapeHTML + * @param {String} string text to be parsed + * @return {String} Text parsed + */ + public escapeHTML = function (string: string) { + const htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + } as HtmlEscapes + + const reUnescapedHtml = /[&<>"']/g + const reHasUnescapedHtml = RegExp(reUnescapedHtml.source) + + return string && reHasUnescapedHtml.test(string) + ? string.replace(reUnescapedHtml, (chr) => htmlEscapes[chr]) + : string + } +} \ No newline at end of file diff --git a/src/interface/components/textEditor/schema.ts b/src/interface/components/textEditor/schema.ts new file mode 100644 index 00000000..4efa83af --- /dev/null +++ b/src/interface/components/textEditor/schema.ts @@ -0,0 +1,274 @@ +import { ISbNode, NodeSchema, MarkSchema, ISbComponentType } from './interfaces' +import { SbHelpers } from './sbHelpers' + +const pick = function (attrs: Attrs, allowed: string[]) { + const h = {} as Attrs + + for (const key in attrs) { + const value = attrs[key] + if (allowed.indexOf(key) > -1 && value !== null) { + h[key] = value + } + } + return h +} + +const isEmailLinkType = (type: string) => type === 'email' + +type Attrs = { + [key: string]: string | number | Array> +} + +// nodes +const horizontal_rule: NodeSchema = () => { + return { + singleTag: 'hr', + } +} +const blockquote: NodeSchema = () => { + return { + tag: 'blockquote', + } +} +const bullet_list: NodeSchema = () => { + return { + tag: 'ul', + } +} +const code_block: NodeSchema = (node: ISbNode) => { + return { + tag: [ + 'pre', + { + tag: 'code', + attrs: node.attrs, + }, + ], + } +} +const hard_break: NodeSchema = () => { + return { + singleTag: 'br', + } +} +const heading: NodeSchema = (node: ISbNode) => { + return { + tag: `h${node.attrs.level}`, + } +} + +const image: NodeSchema = (node: ISbNode) => { + return { + singleTag: [ + { + tag: 'img', + attrs: pick(node.attrs, ['src', 'alt', 'title']), + }, + ], + } +} +const list_item: NodeSchema = () => { + return { + tag: 'li', + } +} +const ordered_list: NodeSchema = () => { + return { + tag: 'ol', + } +} +const paragraph: NodeSchema = () => { + return { + tag: 'p', + } +} + +const emoji: NodeSchema = (node: ISbNode) => { + const attrs = { + ['data-type']: 'emoji', + ['data-name']: node.attrs.name, + emoji: node.attrs.emoji + } + + return { + tag: [ + { + tag: 'span', + attrs: attrs, + }, + ], + } +} + +// marks +const bold: MarkSchema = () => { + return { + tag: 'b', + } +} +const strike: MarkSchema = () => { + return { + tag: 's', + } +} +const underline: MarkSchema = () => { + return { + tag: 'u', + } +} +const strong: MarkSchema = () => { + return { + tag: 'strong', + } +} +const code: MarkSchema = () => { + return { + tag: 'code', + } +} +const italic: MarkSchema = () => { + return { + tag: 'i', + } +} +const link: MarkSchema = (node: ISbNode) => { + if (!node.attrs) { + return { + tag: '', + } + } + const escapeHTML = new SbHelpers().escapeHTML + const attrs = { ...node.attrs } + const { linktype = 'url' } = node.attrs + delete attrs.linktype + + if (attrs.href) { + attrs.href = escapeHTML(node.attrs.href || '') + } + + if (isEmailLinkType(linktype)) { + attrs.href = `mailto:${attrs.href}` + } + + if (attrs.anchor) { + attrs.href = `${attrs.href}#${attrs.anchor}` + delete attrs.anchor + } + + if (attrs.custom) { + for (const key in attrs.custom) { + attrs[key] = attrs.custom[key] + } + delete attrs.custom + } + + return { + tag: [ + { + tag: 'a', + attrs: attrs, + }, + ], + } +} + +const styled: MarkSchema = (node: ISbNode) => { + return { + tag: [ + { + tag: 'span', + attrs: node.attrs, + }, + ], + } +} + +const subscript: MarkSchema = () => { + return { + tag: 'sub', + } +} + +const superscript: MarkSchema = () => { + return { + tag: 'sup' + } +} + +const anchor: MarkSchema = (node: ISbNode) => { + return { + tag: [ + { + tag: 'span', + attrs: node.attrs, + }, + ], + } +} + +const highlight: MarkSchema = (node: ISbNode) => { + if (!node.attrs?.color) return { + tag: '', + } + + const attrs = { + ['style']: `background-color:${node.attrs.color};`, + } + return { + tag: [ + { + tag: 'span', + attrs, + }, + ], + } +} + +const textStyle: MarkSchema = (node: ISbNode) => { + if (!node.attrs?.color) return { + tag: '', + } + + const attrs = { + ['style']: `color:${node.attrs.color}`, + } + return { + tag: [ + { + tag: 'span', + attrs, + }, + ], + } +} + +export default { + nodes: { + horizontal_rule, + blockquote, + bullet_list, + code_block, + hard_break, + heading, + image, + list_item, + ordered_list, + paragraph, + emoji + }, + marks: { + bold, + strike, + underline, + strong, + code, + italic, + link, + styled, + subscript, + superscript, + anchor, + highlight, + textStyle, + }, +} \ No newline at end of file diff --git a/src/interface/main.tsx b/src/interface/main.tsx index 79687d19..f59820db 100644 --- a/src/interface/main.tsx +++ b/src/interface/main.tsx @@ -10,6 +10,7 @@ import font from '../resources/fonts/IconFamily.woff' import ThemeCreator from './pages/ThemeCreator'; import Store from './pages/Store'; +import Editor from './pages/Editor'; browser.storage.local.get().then(({ DarkMode }) => { if (DarkMode) document.documentElement.classList.add('dark'); @@ -51,6 +52,7 @@ root.render( } /> } /> } /> + } /> diff --git a/src/interface/pages/Editor.css b/src/interface/pages/Editor.css new file mode 100644 index 00000000..13529f17 --- /dev/null +++ b/src/interface/pages/Editor.css @@ -0,0 +1,11 @@ +body { + background: transparent !important; +} + +.bn-container[data-color-scheme=dark] { + --bn-colors-editor-background: #18181B00 !important; +} + +.bn-container > [contenteditable="true"] { + height: 100vh !important; +} \ No newline at end of file diff --git a/src/interface/pages/Editor.tsx b/src/interface/pages/Editor.tsx new file mode 100644 index 00000000..b792a5db --- /dev/null +++ b/src/interface/pages/Editor.tsx @@ -0,0 +1,25 @@ +import "@blocknote/core/fonts/inter.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +//import { generateHTML } from '@tiptap/html' +import './Editor.css' + + +export default function Editor() { + const editor = useCreateBlockNote({}); + + /* debounce on change to export to html */ + editor._tiptapEditor.on('update', () => { + window.parent.postMessage({ + type: 'message-html', + data: editor._tiptapEditor.getHTML() + }, '*') + }) + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index 21b86fcd..5cb2d64f 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -231,10 +231,10 @@ function GetLightDarkModeString(darkMode: boolean) { async function addDarkLightToggle() { const tooltipString = GetLightDarkModeString(settingsState.DarkMode); const svgContent = settingsState.DarkMode ? - '' : - ''; + /* html */`` : + /* html */``; - const LightDarkModeButton = stringToHTML(` + const LightDarkModeButton = stringToHTML(/* html */` + `; + const button: HTMLElement = stringToHTML(buttonHTML); + + // Append the new button as the second child of options + firstButton.parentNode?.insertBefore(button.firstChild as Node, firstButton.nextSibling); + + // Add click event listeners to both buttons + container.addEventListener('click', (event: Event) => handleButtonClick(event, container, firstButton)); + } +} + +function handleButtonClick(event: Event, container: HTMLElement, firstButton: HTMLButtonElement): void { + const target = event.target as HTMLElement; + + if (target && target.classList.contains('button')) { + const isBetterEditorButton = target.textContent?.trim() === 'Better Editor'; + + if (isBetterEditorButton) { + activateBetterEditor(container, firstButton); + } else { + deactivateBetterEditor(container, firstButton); + } + } +} + +function activateBetterEditor(container: HTMLElement, firstButton: HTMLButtonElement): void { + firstButton.classList.remove('depressed'); + container.children[1]?.classList.add('depressed'); + + const ckeInner = document.querySelector('.pane .cke_inner') as HTMLElement; + if (ckeInner) ckeInner.style.display = 'none'; + + let extensionEditor: HTMLIFrameElement | null = document.querySelector('.extension-editor') as HTMLIFrameElement; + if (extensionEditor) { + extensionEditor.style.display = 'block'; + } else { + const extensionEditorIframe: HTMLIFrameElement = document.createElement('iframe'); + extensionEditorIframe.src = `${browser.runtime.getURL('src/interface/index.html')}#editor`; + extensionEditorIframe.setAttribute('allowTransparency', 'true'); + extensionEditorIframe.setAttribute('excludeDarkCheck', 'true'); + extensionEditorIframe.classList.add('extension-editor'); + document.getElementById('cke_editor1')?.appendChild(extensionEditorIframe); + } + + extensionEditor = document.querySelector('.extension-editor') as HTMLIFrameElement; + const ckeEditor = document.querySelector('#cke_1_contents iframe.cke_wysiwyg_frame') as HTMLIFrameElement; + + window.addEventListener('message', (event) => handleEditorMessage(event, ckeEditor), { once: true }); +} + +function deactivateBetterEditor(container: HTMLElement, firstButton: HTMLButtonElement): void { + const ckeInner = document.querySelector('.pane .cke_inner') as HTMLElement; + const extensionEditor = document.querySelector('.extension-editor') as HTMLIFrameElement; + + if (ckeInner && extensionEditor) { + ckeInner.style.display = 'block'; + firstButton.classList.add('depressed'); + container.children[1]?.classList.remove('depressed'); + extensionEditor.style.display = 'none'; + } +} + +function handleEditorMessage(event: MessageEvent, ckeEditor: HTMLIFrameElement): void { + if (!event.origin.includes(browser.runtime.id) || event.data.type !== "message-html") return; + + console.log('Message from extension editor', event.data.data); + + if (ckeEditor.contentDocument) { + ckeEditor.contentDocument.body.innerHTML = event.data.data + ``; + } +}