prestorybook

This commit is contained in:
Untone 2024-09-15 19:41:02 +03:00
parent 8824fbab2f
commit ad4bda3c24
16 changed files with 245 additions and 188 deletions

View File

@ -7,19 +7,27 @@ const config: StorybookConfig = {
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'@storybook/addon-themes'
'@storybook/addon-themes',
'@storybook/addon-style-config'
],
framework: {
name: 'storybook-solidjs-vite',
options: {
builder: {
viteConfigPath: './app.config.ts'
viteConfigPath: './vite.config.ts'
}
} as FrameworkOptions
},
docs: {
autodocs: 'tag'
},
viteFinal: (config) => {
if (config.build) {
config.build.sourcemap = true
config.build.minify = process.env.NODE_ENV === 'production'
}
return config
},
previewHead: (head) => `
${head}
<style>

View File

@ -1,5 +1,4 @@
import { withThemeByClassName } from '@storybook/addon-themes'
import '../src/styles/_imports.scss'
const preview = {
parameters: {

38
api/jsonify.js Normal file
View File

@ -0,0 +1,38 @@
// api/convert.js
import { Editor } from '@tiptap/core'
import { base, custom } from 'src/lib/editorOptions'
// Добавьте другие расширения при необходимости
export default function handler(req, res) {
// Разрешаем только метод POST
if (req.method !== 'POST') {
res.status(405).json({ error: 'Method not allowed' })
return
}
// Получаем HTML из тела запроса
const { html } = req.body
if (!html) {
res.status(400).json({ error: 'No HTML content provided' })
return
}
try {
const editor = new Editor({ extensions: [...base, ...custom] })
editor.commands.setContent(html, false, {
parseOptions: {
preserveWhitespace: 'full'
}
})
const jsonOutput = editor.getJSON()
res.status(200).json(jsonOutput)
} catch (error) {
console.error('Ошибка при конвертации:', error)
res.status(500).json({ error: 'Internal Server Error' })
}
}

View File

@ -1,27 +1,5 @@
import { SolidStartInlineConfig, defineConfig } from '@solidjs/start/config'
import { CSSOptions } from 'vite'
// import { visualizer } from 'rollup-plugin-visualizer'
import mkcert from 'vite-plugin-mkcert'
import { PolyfillOptions, nodePolyfills } from 'vite-plugin-node-polyfills'
import sassDts from 'vite-plugin-sass-dts'
const isVercel = Boolean(process?.env.VERCEL)
const isNetlify = Boolean(process?.env.NETLIFY)
const isBun = Boolean(process.env.BUN)
const runtime = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node'
console.info(`[app.config] build for ${runtime}!`)
const polyfillOptions = {
include: ['path', 'stream', 'util'],
exclude: ['http'],
globals: {
Buffer: true
},
overrides: {
fs: 'memfs'
},
protocolImports: true
} as PolyfillOptions
import viteConfig, { runtime } from './vite.config'
export default defineConfig({
nitro: {
@ -34,31 +12,5 @@ export default defineConfig({
https: true
},
devOverlay: true,
vite: {
envPrefix: 'PUBLIC_',
plugins: [!isVercel && mkcert(), nodePolyfills(polyfillOptions), sassDts()],
css: {
preprocessorOptions: {
scss: {
additionalData: '@import "src/styles/imports";\n',
includePaths: ['./public', './src/styles']
}
} as CSSOptions['preprocessorOptions']
},
build: {
target: 'esnext',
sourcemap: true,
rollupOptions: {
// plugins: [visualizer()]
output: {
manualChunks: {
icons: ['./src/components/_shared/Icon/Icon.tsx'],
session: ['./src/context/session.tsx'],
editor: ['./src/context/editor.tsx'],
connect: ['./src/context/connect.tsx']
}
}
}
}
}
vite: viteConfig
} as SolidStartInlineConfig)

View File

@ -63,6 +63,7 @@
"noBarrelFile": "off"
},
"style": {
"noNonNullAssertion": "off",
"noNamespaceImport": "warn",
"useBlockStatements": "off",
"noImplicitBoolean": "off",

View File

@ -82,9 +82,10 @@
"@tiptap/extension-text": "^2.6.6",
"@tiptap/extension-underline": "^2.6.6",
"@tiptap/extension-youtube": "^2.6.6",
"@tiptap/starter-kit": "^2.6.6",
"@types/cookie": "^0.6.0",
"@types/cookie-signature": "^1.1.2",
"@types/node": "^22.5.2",
"@types/node": "^22.5.3",
"@types/throttle-debounce": "^5.0.2",
"@urql/core": "^5.0.6",
"axe-playwright": "^2.0.2",
@ -104,12 +105,12 @@
"patch-package": "^8.0.0",
"prosemirror-history": "^1.4.1",
"prosemirror-trailing-node": "^2.0.9",
"prosemirror-view": "^1.34.1",
"prosemirror-view": "^1.34.2",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "1.76.0",
"solid-js": "^1.8.22",
"solid-popper": "^0.3.0",
"solid-tiptap": "0.7.0",
"solid-tiptap": "^0.7.0",
"solid-transition-group": "^0.2.3",
"storybook": "^8.2.9",
"storybook-solidjs": "^1.0.0-beta.2",
@ -120,6 +121,7 @@
"stylelint-order": "^6.0.4",
"stylelint-scss": "^6.5.1",
"swiper": "^11.1.12",
"terracotta": "^1.0.5",
"throttle-debounce": "^5.0.2",
"tslib": "^2.7.0",
"typescript": "^5.5.4",
@ -134,7 +136,8 @@
},
"overrides": {
"yjs": "13.6.18",
"y-prosemirror": "1.2.12"
"y-prosemirror": "1.2.12",
"prosemirror-view": "1.34.2"
},
"engines": {
"node": ">= 20"

View File

@ -12,11 +12,16 @@ import { SessionProvider } from './context/session'
import { TopicsProvider } from './context/topics'
import { UIProvider } from './context/ui' // snackbar included
import '~/styles/app.scss'
import { AuthToken } from '@authorizerdev/authorizer-js'
export const Providers = (props: { children?: JSX.Element }) => {
const sessionStateChanged = (payload: AuthToken) => {
console.debug(payload)
// TODO: maybe load subs here
}
return (
<LocalizeProvider>
<SessionProvider onStateChangeCallback={console.info}>
<SessionProvider onStateChangeCallback={sessionStateChanged}>
<TopicsProvider>
<FeedProvider>
<MetaProvider>

View File

@ -32,7 +32,7 @@ export const CommentsTree = (props: Props) => {
const { reactionEntities, createShoutReaction, loadReactionsBy } = useReactions()
const comments = createMemo(() =>
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT')
Object.values(reactionEntities()).filter((reaction) => reaction.kind === 'COMMENT')
)
const sortedComments = createMemo(() => {
@ -158,11 +158,11 @@ export const CommentsTree = (props: Props) => {
<SimplifiedEditor
quoteEnabled={true}
imageEnabled={true}
autoFocus={false}
options={{ autofocus: false }}
submitByCtrlEnter={true}
placeholder={t('Write a comment...')}
onSubmit={(value) => handleSubmitComment(value)}
setClear={clearEditor()}
reset={clearEditor()}
isPosting={posting()}
/>
</ShowIfAuthenticated>

View File

@ -26,7 +26,7 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const [isLoading, setIsLoading] = createSignal(false)
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
Object.values(reactionEntities()).some(
(r) =>
r.kind === reactionKind &&
r.created_by.id === author()?.id &&
@ -38,7 +38,7 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const shoutRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter(
Object.values(reactionEntities()).filter(
(r) => ['LIKE', 'DISLIKE'].includes(r.kind) && r.shout.id === props.shout.id && !r.reply_to
)
)

View File

@ -29,17 +29,16 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'sol
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import uniqolor from 'uniqolor'
import { Doc } from 'yjs'
import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session'
import { useSnackbar } from '~/context/ui'
import { Author } from '~/graphql/schema/core.gen'
import { handleImageUpload } from '~/lib/handleImageUpload'
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
import { TextBubbleMenu } from './TextBubbleMenu'
import Article from './extensions/Article'
import { ArticleNode } from './extensions/Article'
import { CustomBlockquote } from './extensions/CustomBlockquote'
import { Figcaption } from './extensions/Figcaption'
import { Figure } from './extensions/Figure'
@ -50,7 +49,7 @@ import { ToggleTextWrap } from './extensions/ToggleTextWrap'
import { TrailingNode } from './extensions/TrailingNode'
import './Prosemirror.scss'
import { Author } from '~/graphql/schema/core.gen'
import { renderUploadedImage } from './renderUploadedImage'
type Props = {
shoutId: number
@ -124,26 +123,8 @@ export const EditorComponent = (props: Props) => {
}
showSnackbar({ body: t('Uploading image') })
const result = await handleImageUpload(uplFile, session()?.access_token || '')
editor()
?.chain()
.focus()
.insertContent({
type: 'figure',
attrs: { 'data-type': 'image' },
content: [
{
type: 'image',
attrs: { src: result.url }
},
{
type: 'figcaption',
content: [{ type: 'text', text: result.originalFilename }]
}
]
})
.run()
const image = await handleImageUpload(uplFile, session()?.access_token || '')
renderUploadedImage(editor() as Editor, image)
} catch (error) {
console.error('[Paste Image Error]:', error)
}
@ -293,7 +274,7 @@ export const EditorComponent = (props: Props) => {
}
}),
TrailingNode,
Article
ArticleNode
],
onTransaction: ({ transaction }) => {
if (transaction.docChanged) {

View File

@ -40,7 +40,6 @@ export const InsertLinkForm = (props: Props) => {
.setLink({ href: checkUrl(value) })
.run()
}
return (
<div>
<InlineForm
@ -49,7 +48,7 @@ export const InsertLinkForm = (props: Props) => {
onClear={handleClearLinkForm}
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
onSubmit={handleLinkFormSubmit}
onClose={() => props.onClose()}
onClose={props.onClose}
/>
</div>
)

View File

@ -1,4 +1,3 @@
import { Editor } from '@tiptap/core'
import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
@ -11,7 +10,7 @@ import { Paragraph } from '@tiptap/extension-paragraph'
import { Placeholder } from '@tiptap/extension-placeholder'
import { Text } from '@tiptap/extension-text'
import { clsx } from 'clsx'
import { Show, createEffect, createReaction, createSignal, on, onCleanup, onMount } from 'solid-js'
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { Portal } from 'solid-js/web'
import {
createEditorTransaction,
@ -20,23 +19,26 @@ import {
useEditorIsEmpty,
useEditorIsFocused
} from 'solid-tiptap'
import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize'
import { useUI } from '~/context/ui'
import { UploadedFile } from '~/types/upload'
import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon'
import { Loading } from '../_shared/Loading'
import { Modal } from '../_shared/Modal'
import { Popover } from '../_shared/Popover'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
import styles from './SimplifiedEditor.module.scss'
import { TextBubbleMenu } from './TextBubbleMenu'
import { UploadModalContent } from './UploadModalContent'
import { Figcaption } from './extensions/Figcaption'
import { Figure } from './extensions/Figure'
import { Editor } from '@tiptap/core'
import { useUI } from '~/context/ui'
import { Modal } from '../_shared/Modal/Modal'
import styles from './SimplifiedEditor.module.scss'
type Props = {
placeholder: string
initialContent?: string
@ -69,27 +71,103 @@ const SimplifiedEditor = (props: Props) => {
const { showModal, hideModal } = useUI()
const [counter, setCounter] = createSignal<number>(0)
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false)
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
const { editor, setEditor } = useEditorContext()
const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH
let editorEl: HTMLDivElement | undefined
let wrapperEditorElRef: HTMLElement | undefined
let textBubbleMenuRef: HTMLDivElement | undefined
let linkBubbleMenuRef: HTMLDivElement | undefined
// Extend the Figure extension to include Figcaption
const ImageFigure = Figure.extend({
name: 'capturedImage',
content: 'figcaption image'
})
createEffect(
on(
() => editorElement(),
(ee: HTMLDivElement | undefined) => {
if (ee && textBubbleMenuRef && linkBubbleMenuRef) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
class: styles.simplifiedEditorField
}
},
extensions: [
Document,
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false
}).configure({
autolink: true,
openOnClick: false
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote
}
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return false
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
}
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef,
shouldShow: ({ state }) => {
const { selection } = state
const { empty } = selection
return !empty && shouldShowLinkBubbleMenu()
},
tippyOptions: {
placement: 'bottom'
}
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder
})
],
autofocus: props.autoFocus,
content: props.initialContent || null
}))
const editorInstance = freshEditor()
if (!editorInstance) return
setEditor(editorInstance)
}
},
{ defer: true }
)
)
const isEmpty = useEditorIsEmpty(() => editor())
const isFocused = useEditorIsFocused(() => editor())
const isActive = (name: string) =>
createEditorTransaction(
() => editor(),
(ed) => ed?.isActive(name)
(ed) => {
return ed?.isActive(name)
}
)
const html = useEditorHTML(() => editor())
@ -127,6 +205,16 @@ const SimplifiedEditor = (props: Props) => {
editor()?.commands.clearContent(true)
}
createEffect(() => {
if (props.setClear) {
editor()?.commands.clearContent(true)
}
if (props.resetToInitial) {
editor()?.commands.clearContent(true)
if (props.initialContent) editor()?.commands.setContent(props.initialContent)
}
})
const handleKeyDown = (event: KeyboardEvent) => {
if (isEmpty() || !isFocused()) {
return
@ -155,89 +243,19 @@ const SimplifiedEditor = (props: Props) => {
window.removeEventListener('keydown', handleKeyDown)
editor()?.destroy()
})
console.debug('[SimplifiedEditor] mounted')
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: editorEl as HTMLDivElement,
editorProps: {
attributes: {
class: styles.simplifiedEditorField
}
},
extensions: [
Document,
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false
}).configure({
autolink: true,
openOnClick: false
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote
}
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return false
const { selection } = state
return view.hasFocus() && !selection.empty
}
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef,
shouldShow: ({ state }) =>
state.selection && !state.selection.empty && shouldShowLinkBubbleMenu(),
tippyOptions: {
placement: 'bottom'
}
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder
})
],
autofocus: props.autoFocus,
content: props.initialContent || null
}))
const ed = freshEditor()
ed && setEditor(ed)
})
createReaction(
on(
editor,
(e) => {
e?.commands.clearContent(props.resetToInitial || props.setClear)
props.initialContent && e?.commands.setContent(props.initialContent)
},
{}
)
)
if (props.onChange) {
createEffect(() => {
props.onChange?.(html() || '')
})
}
createEffect(
on(
html,
(content) => {
content && setCounter(editor()?.storage.characterCount.characters())
props.onChange?.(content || '')
},
{}
)
)
createEffect(() => {
if (html()) {
setCounter(editor()?.storage.characterCount.characters())
}
})
const maxHeightStyle = {
overflow: 'auto',
@ -272,7 +290,7 @@ const SimplifiedEditor = (props: Props) => {
<Show when={props.label && counter() > 0}>
<div class={styles.label}>{props.label}</div>
</Show>
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={(el) => (editorEl = el)} />
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={setEditorElement} />
<Show when={!props.onlyBubbleControls}>
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
<div class={styles.actions}>
@ -343,7 +361,7 @@ const SimplifiedEditor = (props: Props) => {
</div>
<Show when={!props.onChange}>
<div class={styles.buttons}>
<Show when={props.isCancelButtonVisible}>
<Show when={isCancelButtonVisible()}>
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
</Show>
<Show when={!props.isPosting} fallback={<Loading />}>
@ -387,4 +405,4 @@ const SimplifiedEditor = (props: Props) => {
)
}
export default SimplifiedEditor
export default SimplifiedEditor // "export default" need to use for asynchronous (lazy) imports in the comments tree

View File

@ -10,7 +10,7 @@ declare module '@tiptap/core' {
}
}
export default Node.create({
export const ArticleNode = Node.create({
name: 'article',
group: 'block',
content: 'block+',
@ -65,3 +65,5 @@ export default Node.create({
}
}
})
export default ArticleNode

View File

@ -1,7 +1,7 @@
import { useNavigate } from '@solidjs/router'
import { clsx } from 'clsx'
import { Show, createEffect, createMemo, createSignal, lazy, onMount } from 'solid-js'
import { createStore } from 'solid-js/store'
import { Button } from '~/components/_shared/Button'
import { Icon } from '~/components/_shared/Icon'
import { Image } from '~/components/_shared/Image'
@ -11,11 +11,10 @@ import { useSession } from '~/context/session'
import { useTopics } from '~/context/topics'
import { useSnackbar, useUI } from '~/context/ui'
import { Topic } from '~/graphql/schema/core.gen'
import { UploadedFile } from '~/types/upload'
import { TopicSelect, UploadModalContent } from '../../Editor'
import { Modal } from '../../_shared/Modal'
import { useNavigate } from '@solidjs/router'
import { UploadedFile } from '~/types/upload'
import stylesBeside from '../../Feed/Beside.module.scss'
import styles from './PublishSettings.module.scss'

View File

@ -73,9 +73,10 @@ export const AuthorsProvider = (props: { children: JSX.Element }) => {
console.debug('[context.authors] storing new authors:', newAuthors)
setAuthors((prevAuthors) => {
const updatedAuthors = { ...prevAuthors }
newAuthors.forEach((author) => {
updatedAuthors[author.slug] = author
})
Array.isArray(newAuthors) &&
newAuthors.forEach((author) => {
updatedAuthors[author.slug] = author
})
return updatedAuthors
})
}

51
vite.config.ts Normal file
View File

@ -0,0 +1,51 @@
import { CSSOptions } from 'vite'
// import { visualizer } from 'rollup-plugin-visualizer'
import mkcert from 'vite-plugin-mkcert'
import { PolyfillOptions, nodePolyfills } from 'vite-plugin-node-polyfills'
import sassDts from 'vite-plugin-sass-dts'
const isVercel = Boolean(process?.env.VERCEL)
const isNetlify = Boolean(process?.env.NETLIFY)
const isBun = Boolean(process.env.BUN)
export const runtime = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node'
console.info(`[app.config] build for ${runtime}!`)
const polyfillOptions = {
include: ['path', 'stream', 'util'],
exclude: ['http'],
globals: {
Buffer: true
},
overrides: {
fs: 'memfs'
},
protocolImports: true
} as PolyfillOptions
export default {
envPrefix: 'PUBLIC_',
plugins: [!isVercel && mkcert(), nodePolyfills(polyfillOptions), sassDts()],
css: {
preprocessorOptions: {
scss: {
additionalData: '@import "src/styles/imports";\n',
includePaths: ['./public', './src/styles']
}
} as CSSOptions['preprocessorOptions']
},
build: {
target: 'esnext',
sourcemap: true,
rollupOptions: {
// plugins: [visualizer()]
output: {
manualChunks: {
icons: ['./src/components/_shared/Icon/Icon.tsx'],
session: ['./src/context/session.tsx'],
editor: ['./src/context/editor.tsx'],
connect: ['./src/context/connect.tsx']
}
}
}
}
}