feat: improve editor + add exporting

This commit is contained in:
sethburkart123
2024-06-25 17:02:52 +10:00
parent 87e60130cc
commit 53cab9701d
10 changed files with 68 additions and 1333 deletions
@@ -1,11 +0,0 @@
const METHOD = {
GET: 'get',
DELETE: 'delete',
POST: 'post',
PUT: 'put',
} as const
type ObjectValues<T> = T[keyof T]
type Method = ObjectValues<typeof METHOD>
export default Method
@@ -1,329 +0,0 @@
import { ResponseFn } from './sbFetch'
export interface ISbStoriesParams
extends Partial<ISbStoryData>,
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<T extends string> {
_uid?: string
component?: T
_editable?: string
}
export interface ISbStoryData<
Content = ISbComponentType<string> & { [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<IMemoryType | void>
set: (key: string, content: ISbResult) => Promise<void>
getAll: () => Promise<IMemoryType | void>
flush: () => Promise<void>
}
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<ISbComponentType<any>>
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<string> & { [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<ISbResult>
export type ArrayFn = (...args: any) => void
export type HtmlEscapes = {
[key: string]: string
}
export interface ISbCustomFetch extends Omit<RequestInit, 'method'> {}
@@ -1,396 +0,0 @@
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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
} 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<T extends any[], R> {
(...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<any, any>) {
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(/<img/g, `<img ${imageAttributes.trim()}`)
}
if (typeof options !== 'boolean' && (options.sizes || options.srcset)) {
html = html.replace(/<img.*?src=["|'](.*?)["|']/g, (value: string) => {
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(/<img/g, `<img ${renderImageAttributes.trim()}`)
}
return value
})
}
return html
}
private renderNode(item: ISbRichtext) {
const html = []
if (item.marks) {
item.marks.forEach((m: any) => {
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 `</${tags}>`
}
const all = tags
.slice(0)
.reverse()
.map((tag) => {
if (tag.constructor === String) {
return `</${tag}>`
} else {
return `</${tag.tag}>`
}
})
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
@@ -1,192 +0,0 @@
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<ISbResponse | Error>
*/
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<ISbResponse | ISbError> {
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<ISbResponse | ISbError> {
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
@@ -1,101 +0,0 @@
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<any> => {
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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
} as HtmlEscapes
const reUnescapedHtml = /[&<>"']/g
const reHasUnescapedHtml = RegExp(reUnescapedHtml.source)
return string && reHasUnescapedHtml.test(string)
? string.replace(reUnescapedHtml, (chr) => htmlEscapes[chr])
: string
}
}
@@ -1,274 +0,0 @@
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<ISbComponentType<any>>
}
// 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,
},
}
+1
View File
@@ -18,6 +18,7 @@ browser.storage.local.get().then(({ DarkMode }) => {
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.classList.add('iconFamily')
style.innerHTML = `
@font-face {
font-family: 'IconFamily';
+4
View File
@@ -8,4 +8,8 @@ body {
.bn-container > [contenteditable="true"] {
height: 100vh !important;
}
.ProseMirror {
background: transparent !important;
}
+1 -3
View File
@@ -2,18 +2,16 @@ 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()
data: editor._tiptapEditor.getHTML(),
}, '*')
})
+62 -27
View File
@@ -6,41 +6,66 @@ export default async function handleComposeMessage(): Promise<void> {
console.log('COMPOSE MESSAGE!');
const container: HTMLElement | null = document.querySelector('.pane .footer .pillbox');
const firstButton: HTMLButtonElement | null = document.querySelector('.pane .footer .pillbox button.first') as HTMLButtonElement;
const simpleEditorButton: HTMLButtonElement | null = document.querySelector('.pane .footer .pillbox button.first') as HTMLButtonElement;
if (container && firstButton) {
if (container && simpleEditorButton) {
const buttonHTML = /* html */ `
<button class="button">
<button class="button" id="betterEditorButton">
Better Editor
</button>
`;
const button: HTMLElement = stringToHTML(buttonHTML);
// Append the new button as the second child of options
firstButton.parentNode?.insertBefore(button.firstChild as Node, firstButton.nextSibling);
// Check if the button already exists
if (!container.querySelector('#betterEditorButton')) {
// Insert the new button after the Simple editor button
simpleEditorButton.insertAdjacentElement('afterend', button.firstElementChild as HTMLElement);
}
// Add click event listeners to both buttons
container.addEventListener('click', (event: Event) => handleButtonClick(event, container, firstButton));
// Add click event listeners to the container (event delegation)
container.addEventListener('click', handleButtonClick);
}
}
function handleButtonClick(event: Event, container: HTMLElement, firstButton: HTMLButtonElement): void {
function handleButtonClick(event: MouseEvent): void {
console.log('handleButtonClick', event);
const target = event.target as HTMLElement;
if (target && target.classList.contains('button')) {
const isBetterEditorButton = target.textContent?.trim() === 'Better Editor';
if (target.tagName !== 'BUTTON') return;
if (isBetterEditorButton) {
activateBetterEditor(container, firstButton);
} else {
deactivateBetterEditor(container, firstButton);
}
const container = target.closest('.pillbox') as HTMLElement;
if (!container) return;
const simpleEditorButton = container.querySelector('button.first') as HTMLButtonElement;
const betterEditorButton = container.querySelector('#betterEditorButton') as HTMLButtonElement;
if (!simpleEditorButton || !betterEditorButton) {
console.error('Could not find Simple Editor or Better Editor buttons');
return;
}
const isBetterEditorButton = target === betterEditorButton;
const isSimpleEditorButton = target === simpleEditorButton;
if (isBetterEditorButton) {
activateBetterEditor(simpleEditorButton, betterEditorButton);
} else if (isSimpleEditorButton) {
activateSimpleEditor(simpleEditorButton, betterEditorButton);
} else {
deactivateBetterEditor(simpleEditorButton, betterEditorButton);
}
container.querySelectorAll('button').forEach(btn => btn.classList.remove('depressed'));
target.classList.add('depressed');
}
function activateBetterEditor(container: HTMLElement, firstButton: HTMLButtonElement): void {
firstButton.classList.remove('depressed');
container.children[1]?.classList.add('depressed');
function activateBetterEditor(simpleEditorButton: HTMLButtonElement, betterEditorButton: HTMLButtonElement): void {
// Programmatically click the Simple Editor button first
simpleEditorButton.click();
// Then proceed with Better Editor activation
simpleEditorButton.classList.remove('depressed');
betterEditorButton.classList.add('depressed');
const ckeInner = document.querySelector('.pane .cke_inner') as HTMLElement;
if (ckeInner) ckeInner.style.display = 'none';
@@ -63,23 +88,33 @@ function activateBetterEditor(container: HTMLElement, firstButton: HTMLButtonEle
window.addEventListener('message', (event) => handleEditorMessage(event, ckeEditor), { once: true });
}
function deactivateBetterEditor(container: HTMLElement, firstButton: HTMLButtonElement): void {
function activateSimpleEditor(simpleEditorButton: HTMLButtonElement, betterEditorButton: HTMLButtonElement): void {
simpleEditorButton.classList.add('depressed');
betterEditorButton.classList.remove('depressed');
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';
}
if (ckeInner) ckeInner.style.removeProperty('display')
if (extensionEditor) extensionEditor.style.display = 'none'
}
function deactivateBetterEditor(simpleEditorButton: HTMLButtonElement, betterEditorButton: HTMLButtonElement): void {
const ckeInner = document.querySelector('.pane .cke_inner') as HTMLElement;
const ckeContents = document.querySelector('.pane .cke_contents') as HTMLElement;
const extensionEditor = document.querySelector('.extension-editor') as HTMLIFrameElement;
if (ckeInner) ckeInner.style.removeProperty('display');
if (ckeContents) ckeContents.style.removeProperty('display');
if (extensionEditor) extensionEditor.style.removeProperty('display');
simpleEditorButton.classList.remove('depressed');
betterEditorButton.classList.remove('depressed');
}
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 + `<style>${styles}</style>`;
}