gridfix+editor-wip
Some checks failed
deploy / testbuild (push) Failing after 1m10s
deploy / Update templates on Mailgun (push) Has been skipped

This commit is contained in:
Untone 2024-10-08 22:50:58 +03:00
parent 6551263fe8
commit 8ba69a5f7f
10 changed files with 494 additions and 349 deletions

18
prompt-20steps.txt Normal file
View File

@ -0,0 +1,18 @@
Begin by enclosing all thoughts within <thinking> tags, exploring multiple angles and approaches.
Break down the solution into clear steps within <step> tags. Start with a 20-step budget, requesting more for complex problems if needed.
Use <count> tags after each step to show the remaining budget. Stop when reaching 0.
Continuously adjust your reasoning based on intermediate results and reflections, adapting your strategy as you progress.
Regularly evaluate progress using <reflection> tags. Be critical and honest about your reasoning process.
Assign a quality score between 0.0 and 1.0 using <reward> tags after each reflection. Use this to guide your approach:
0.8+: Continue current approach
0.5-0.7: Consider minor adjustments
Below 0.5: Seriously consider backtracking and trying a different approach
If unsure or if reward score is low, backtrack and try a different approach, explaining your decision within <thinking> tags.
For mathematical problems, show all work explicitly using LaTeX for formal notation and provide detailed proofs.
Explore multiple solutions individually if possible, comparing approaches in reflections.
Use thoughts as a scratchpad, writing out all calculations and reasoning explicitly.
Synthesize the final answer within <answer> tags, providing a clear, concise summary.
Conclude with a final reflection on the overall solution, discussing effectiveness, challenges, and solutions. Assign a final reward score.

View File

@ -1,4 +1,5 @@
import { HocuspocusProvider } from '@hocuspocus/provider'
import { UploadFile } from '@solid-primitives/upload'
import { Editor, EditorOptions, isTextSelection } from '@tiptap/core'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { CharacterCount } from '@tiptap/extension-character-count'
@ -6,9 +7,10 @@ import { Collaboration } from '@tiptap/extension-collaboration'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
import { FloatingMenu } from '@tiptap/extension-floating-menu'
import { Placeholder } from '@tiptap/extension-placeholder'
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { createTiptapEditor } from 'solid-tiptap'
import uniqolor from 'uniqolor'
import { Doc, Transaction } from 'yjs'
import { Doc } from 'yjs'
import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session'
@ -24,6 +26,8 @@ import { IncutBubbleMenu } from './Toolbar/IncutBubbleMenu'
import { TextBubbleMenu } from './Toolbar/TextBubbleMenu'
import './Editor.module.scss'
import { isServer } from 'solid-js/web'
import { Panel } from './Panel/Panel'
export type EditorComponentProps = {
shoutId: number
@ -37,35 +41,61 @@ const providers: Record<string, HocuspocusProvider> = {}
export const EditorComponent = (props: EditorComponentProps) => {
const { t } = useLocalize()
const { session } = useSession()
const { session, requireAuthentication } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
const { showSnackbar } = useSnackbar()
const { createEditor, countWords, editor } = useEditorContext()
const { countWords, setEditing } = useEditorContext()
const [editorOptions, setEditorOptions] = createSignal<Partial<EditorOptions>>({})
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLElement | undefined>()
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLElement | undefined>()
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLElement | undefined>()
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>()
const [editor, setEditor] = createSignal<Editor | null>(null)
const [menusInitialized, setMenusInitialized] = createSignal(false)
// store tiptap editor in context provider's signal to use it in Panel
createEffect(() => setEditing(editor() || undefined))
/**
* Создает экземпляр редактора с заданными опциями
* @param opts Опции редактора
*/
const createEditorInstance = (opts?: Partial<EditorOptions>) => {
if (!opts?.element) {
console.error('Editor options or element is missing')
return
}
console.log('stage 2: create editor instance without menus', opts)
const old = editor() || { options: {} }
const fresh = createTiptapEditor(() => ({
...old?.options,
...opts,
element: opts.element as HTMLElement
}))
if (old instanceof Editor) old?.destroy()
setEditor(fresh() || null)
}
const handleClipboardPaste = async () => {
try {
const clipboardItems = await navigator.clipboard.read()
const clipboardItems: ClipboardItems = await navigator.clipboard.read()
if (clipboardItems.length === 0) return
const [clipboardItem] = clipboardItems
const { types } = clipboardItem
const imageType = types.find((type) => allowedImageTypes.has(type))
const imageType: string | undefined = types.find((type) => allowedImageTypes.has(type))
if (!imageType) return
const blob = await clipboardItem.getType(imageType)
const extension = imageType.split('/')[1]
const file = new File([blob], `clipboardImage.${extension}`)
const uplFile = {
const uplFile: UploadFile = {
source: blob.toString(),
name: file.name,
size: file.size,
@ -73,7 +103,10 @@ export const EditorComponent = (props: EditorComponentProps) => {
}
showSnackbar({ body: t('Uploading image') })
const image = await handleImageUpload(uplFile, session()?.access_token || '')
const image: { url: string; originalFilename?: string } = await handleImageUpload(
uplFile,
session()?.access_token || ''
)
renderUploadedImage(editor() as Editor, image)
} catch (error) {
console.error('[Paste Image Error]:', error)
@ -81,180 +114,243 @@ export const EditorComponent = (props: EditorComponentProps) => {
return false
}
createEffect(
on([editorOptions, editorElRef, author], ([opts, element, a]) => {
if (!opts && a && element) {
const options = {
element: editorElRef()!,
editorProps: {
attributes: { class: 'articleEditor' },
transformPastedHTML: (c: string) => c.replaceAll(/<img.*?>/g, ''),
handlePaste: handleClipboardPaste
},
extensions: [
...base,
...custom,
...extended,
Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }),
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
// menus
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef(),
shouldShow: ({ editor: e, view, state: { doc, selection }, from, to }) => {
const isEmptyTextBlock =
doc.textBetween(from, to).length === 0 && isTextSelection(selection)
isEmptyTextBlock &&
e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
setIsCommonMarkup(e?.isActive('figcaption'))
const result =
(view.hasFocus() &&
!selection.empty &&
!isEmptyTextBlock &&
!e.isActive('image') &&
!e.isActive('figure')) ||
e.isActive('footnote') ||
(e.isActive('figcaption') && !selection.empty)
setShouldShowTextBubbleMenu(result)
return result
},
tippyOptions: {
onHide: () => editor()?.commands.focus() as false
}
}),
BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef(),
shouldShow: ({ editor: e, view, state }) =>
view.hasFocus() && !state.selection.empty && e.isActive('blockquote')
}),
BubbleMenu.configure({
pluginKey: 'figureBubbleMenu',
element: figureBubbleMenuRef(),
shouldShow: ({ editor: e, view, state }) =>
view.hasFocus() && !state.selection.empty && e.isActive('figure')
}),
BubbleMenu.configure({
pluginKey: 'incutBubbleMenu',
element: incutBubbleMenuRef(),
shouldShow: ({ editor: e, view, state }) =>
view.hasFocus() && !state.selection.empty && e.isActive('figcaption')
}),
FloatingMenu.configure({
element: floatingMenuRef(),
pluginKey: 'floatingMenu',
shouldShow: ({ editor: e, state: { selection } }) => {
const isRootDepth = selection.$anchor.depth === 1
if (!(isRootDepth && selection.empty)) return false
return !(e.isActive('codeBlock') || e.isActive('heading'))
}
})
// dynamic
// Collaboration.configure({ document: yDocs[docName] }),
// CollaborationCursor.configure({ provider: providers[docName], user: { name: a.name, color: uniqolor(a.slug).color } }),
],
onTransaction({ transaction, editor }: { transaction: Transaction; editor: Editor }) {
if (transaction.changed) {
// Get the current HTML content from the editor
const html = editor.getHTML()
// Trigger the onChange callback with the updated HTML
html && props.onChange(html)
// Get the word count from the editor's storage (using CharacterCount)
const wordCount = editor.storage.characterCount.words()
// Update the word count
wordCount && countWords(wordCount)
}
},
content: props.initialContent || ''
// stage 0: update editor options
const setupEditor = () => {
console.log('stage 0: update editor options')
const options: Partial<EditorOptions> = {
element: editorElRef()!,
editorProps: {
attributes: { class: 'articleEditor' },
transformPastedHTML: (c: string) => c.replaceAll(/<img.*?>/g, ''),
handlePaste: (_view, _event, _slice) => {
handleClipboardPaste().then((result) => result)
return false
}
setEditorOptions(options as unknown as Partial<EditorOptions>)
createEditor(options as unknown as Partial<EditorOptions>)
}
})
)
createEffect(
on(
[
editor,
() => !props.disableCollaboration,
() => `shout-${props.shoutId}`,
() => session()?.access_token || '',
author
},
extensions: [
...base,
...custom,
...extended,
Placeholder.configure({
placeholder: t('Add a link or click plus to embed media')
}),
CharacterCount.configure()
],
([e, collab, docName, token, profile]) => {
if (!e) return
if (!yDocs[docName]) {
yDocs[docName] = new Doc()
onTransaction({ transaction, editor }) {
if (transaction.docChanged) {
const html = editor.getHTML()
html && props.onChange(html)
const wordCount: number = editor.storage.characterCount.words()
const charsCount: number = editor.storage.characterCount.characters()
wordCount && countWords({ words: wordCount, characters: charsCount })
}
},
content: props.initialContent ?? null
}
console.log('Editor options created:', options)
setEditorOptions(() => options)
}
if (!providers[docName]) {
providers[docName] = new HocuspocusProvider({
url: 'wss://hocuspocus.discours.io',
name: docName,
document: yDocs[docName],
token
})
}
collab &&
createEditor({
...editorOptions(),
extensions: [
...(editor()?.options.extensions || []),
Collaboration.configure({ document: yDocs[docName] }),
CollaborationCursor.configure({
provider: providers[docName],
user: { name: profile.name, color: uniqolor(profile.slug).color }
})
]
})
}
)
)
// stage 1: create editor options when got author profile
createEffect(
on(editorElRef, (ee: HTMLElement | undefined) => {
ee?.addEventListener('focus', (_event) => {
if (editor()?.isActive('figcaption')) {
editor()?.commands.focus()
}
})
on([editorOptions, author], ([opts, a]: [Partial<EditorOptions> | undefined, Author | undefined]) => {
if (isServer) return
console.log('stage 1: create editor options when got author profile', { opts, a })
const noOptions = !opts || Object.keys(opts).length === 0
noOptions && a && setTimeout(setupEditor, 1)
})
)
// Перенос всех эффектов, зависящих от editor, внутрь onMount
onMount(() => {
console.log('Editor component mounted')
editorElRef()?.addEventListener('focus', handleFocus)
requireAuthentication(() => {
setTimeout(() => {
setupEditor()
// Создаем экземпляр редактора после монтирования
createEditorInstance(editorOptions())
// Инициализируем меню после создания редактора
if (editor()) {
initializeMenus()
}
// Инициализируем коллаборацию если необходимо
if (!props.disableCollaboration) {
initializeCollaboration()
}
}, 1200)
}, 'edit')
})
const initializeMenus = () => {
if (menusInitialized() || !editor()) return
console.log('stage 3: initialize menus when editor instance is ready')
if (
textBubbleMenuRef() &&
blockquoteBubbleMenuRef() &&
figureBubbleMenuRef() &&
incutBubbleMenuRef() &&
floatingMenuRef()
) {
const menus = [
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef(),
shouldShow: ({ editor: e, view, state: { doc, selection }, from, to }) => {
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
isEmptyTextBlock &&
e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
setIsCommonMarkup(e?.isActive('figcaption'))
const result =
(view.hasFocus() &&
!selection.empty &&
!isEmptyTextBlock &&
!e.isActive('image') &&
!e.isActive('figure')) ||
e.isActive('footnote') ||
(e.isActive('figcaption') && !selection.empty)
setShouldShowTextBubbleMenu(result)
return result
},
tippyOptions: {
onHide: () => editor()?.commands.focus() as false
}
}),
BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef(),
shouldShow: ({ editor: e, view, state }) =>
view.hasFocus() && !state.selection.empty && e?.isActive('blockquote')
}),
BubbleMenu.configure({
pluginKey: 'figureBubbleMenu',
element: figureBubbleMenuRef(),
shouldShow: ({ editor: e, view, state }) =>
view.hasFocus() && !state.selection.empty && e?.isActive('figure')
}),
BubbleMenu.configure({
pluginKey: 'incutBubbleMenu',
element: incutBubbleMenuRef(),
shouldShow: ({ editor: e, view, state }) =>
view.hasFocus() && !state.selection.empty && e?.isActive('figcaption')
}),
FloatingMenu.configure({
element: floatingMenuRef(),
pluginKey: 'floatingMenu',
shouldShow: ({ editor: e, state: { selection } }) => {
const isRootDepth = selection.$anchor.depth === 1
const show =
isRootDepth && selection.empty && !(e?.isActive('codeBlock') || e?.isActive('heading'))
console.log('FloatingMenu shouldShow:', show)
return show
}
})
]
const extensions = [...(editorOptions().extensions || []), ...menus]
setEditorOptions((prev) => ({ ...prev, extensions }))
console.log('Editor menus initialized:', extensions)
setMenusInitialized(true)
} else {
console.error('Some menu references are missing')
}
}
const initializeCollaboration = () => {
if (!editor()) {
console.error('Editor is not initialized')
return
}
try {
const docName = `shout-${props.shoutId}`
const token = session()?.access_token || ''
const profile = author()
if (!(token && profile)) {
throw new Error('Missing authentication data')
}
if (!yDocs[docName]) {
yDocs[docName] = new Doc()
}
if (!providers[docName]) {
providers[docName] = new HocuspocusProvider({
url: 'wss://hocuspocus.discours.io',
name: docName,
document: yDocs[docName],
token
})
console.log(`HocuspocusProvider установлен для ${docName}`)
}
setEditorOptions((prev: Partial<EditorOptions>) => {
const extensions = [...(prev.extensions || [])]
extensions.push(
Collaboration.configure({ document: yDocs[docName] }),
CollaborationCursor.configure({
provider: providers[docName],
user: { name: profile.name, color: uniqolor(profile.slug).color }
})
)
console.log('collab extensions added:', extensions)
return { ...prev, extensions }
})
} catch (error) {
console.error('Error initializing collaboration:', error)
showSnackbar({ body: t('Failed to initialize collaboration') })
}
}
const handleFocus = (event: FocusEvent) => {
console.log('handling focus event', event)
if (editor()?.isActive('figcaption')) {
editor()?.commands.focus()
console.log('active figcaption detected, focusing editor')
}
}
onCleanup(() => {
editorElRef()?.removeEventListener('focus', handleFocus)
editor()?.destroy()
})
return (
<>
<div>
<Show when={editor()} keyed>
{(ed: Editor) => (
<>
<TextBubbleMenu
shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={isCommonMarkup()}
editor={ed}
ref={setTextBubbleMenuRef}
/>
<BlockquoteBubbleMenu editor={ed} ref={setBlockquoteBubbleMenuRef} />
<FigureBubbleMenu editor={ed} ref={setFigureBubbleMenuRef} />
<IncutBubbleMenu editor={ed} ref={setIncutBubbleMenuRef} />
<EditorFloatingMenu editor={ed} ref={setFloatingMenuRef} />
</>
)}
</Show>
</div>
<div class="row">
<div class="col-md-5" />
<div class="col-md-12">
<div ref={setEditorElRef} id="editorBody" />
</div>
</div>
<Show when={editor()}>
<TextBubbleMenu
shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={isCommonMarkup()}
editor={editor() as Editor}
ref={setTextBubbleMenuRef}
/>
<BlockquoteBubbleMenu ref={setBlockquoteBubbleMenuRef} editor={editor() as Editor} />
<FigureBubbleMenu editor={editor() as Editor} ref={setFigureBubbleMenuRef} />
<IncutBubbleMenu editor={editor() as Editor} ref={setIncutBubbleMenuRef} />
<EditorFloatingMenu editor={editor() as Editor} ref={setFloatingMenuRef} />
<Show when={props.shoutId}>
<Panel shoutId={props.shoutId} />
</Show>
</>
)

View File

@ -30,7 +30,7 @@ export const Panel = (props: Props) => {
saveShout,
saveDraft,
publishShout,
editor
editing: editor
} = useEditorContext()
let containerRef: HTMLElement | undefined

View File

@ -23,7 +23,10 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
const isActive = (name: string, attributes?: Record<string, string | number>) =>
createEditorTransaction(
() => props.editor,
() => {
console.log('isActive', name, attributes)
return props.editor
},
(editor) => editor?.isActive(name, attributes)
)

View File

@ -5,7 +5,7 @@ import { Popover } from '~/components/_shared/Popover'
import styles from '../MiniEditor.module.scss'
interface ControlProps {
export interface ControlProps {
editor: Editor | undefined
title: string
key: string

View File

@ -4,7 +4,6 @@ import { Show, createEffect, createSignal, lazy, on, onCleanup, onMount } from '
import { createStore } from 'solid-js/store'
import { debounce } from 'throttle-debounce'
import { EditorComponent } from '~/components/Editor/Editor'
import { Panel } from '~/components/Editor/Panel/Panel'
import { DropArea } from '~/components/_shared/DropArea'
import { Icon } from '~/components/_shared/Icon'
import { InviteMembers } from '~/components/_shared/InviteMembers'
@ -265,6 +264,164 @@ export const EditView = (props: Props) => {
setIsLeadVisible(true)
}
const HeadingActions = () => {
return (
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
<Show when={props.shout}>
<div class={styles.headingActions}>
<Show when={!isSubtitleVisible() && props.shout.layout !== 'audio'}>
<div class={styles.action} onClick={showSubtitleInput}>
{t('Add subtitle')}
</div>
</Show>
<Show when={!isLeadVisible() && props.shout.layout !== 'audio'}>
<div class={styles.action} onClick={showLeadInput}>
{t('Add intro')}
</div>
</Show>
</div>
<>
<div class={clsx({ [styles.audioHeader]: props.shout.layout === 'audio' })}>
<div class={styles.inputContainer}>
<GrowingTextarea
allowEnterKey={true}
value={(value) => handleTitleInputChange(value)}
class={styles.titleInput}
placeholder={articleTitle()}
initialValue={form.title}
maxLength={MAX_HEADER_LIMIT}
/>
<Show when={formErrors.title}>
<div class={styles.validationError}>{formErrors.title}</div>
</Show>
<Show when={props.shout.layout === 'audio'}>
<div class={styles.additional}>
<input
type="text"
placeholder={t('Artist...')}
class={styles.additionalInput}
value={mediaItems()[0]?.artist || ''}
onChange={(event) => handleBaseFieldsChange('artist', event.target.value)}
/>
<input
type="number"
min="1900"
max={new Date().getFullYear()}
step="1"
class={styles.additionalInput}
placeholder={t('Release date...')}
value={mediaItems()[0]?.date || ''}
onChange={(event) => handleBaseFieldsChange('date', event.target.value)}
/>
<input
type="text"
placeholder={t('Genre...')}
class={styles.additionalInput}
value={mediaItems()[0]?.genre || ''}
onChange={(event) => handleBaseFieldsChange('genre', event.target.value)}
/>
</div>
</Show>
<Show when={props.shout.layout !== 'audio'}>
<Show when={isSubtitleVisible()}>
<GrowingTextarea
textAreaRef={setSubtitleInput}
allowEnterKey={false}
value={(value) => handleInputChange('subtitle', value || '')}
class={styles.subtitleInput}
placeholder={t('Subheader')}
initialValue={form.subtitle || ''}
maxLength={MAX_HEADER_LIMIT}
/>
</Show>
<Show when={isLeadVisible()}>
<MicroEditor
placeholder={t('A short introduction to keep the reader interested')}
content={form.lead}
onChange={(value: string) => handleInputChange('lead', value)}
/>
</Show>
</Show>
</div>
<Show when={props.shout.layout === 'audio'}>
<Show
when={form.coverImageUrl}
fallback={
<DropArea
isSquare={true}
placeholder={t('Add cover')}
description={
<>
{t('min. 1400×1400 pix')}
<br />
{t('jpg, .png, max. 10 mb.')}
</>
}
isMultiply={false}
fileType={'image'}
onUpload={(val) => handleInputChange('coverImageUrl', val[0].url)}
/>
}
>
<div
class={styles.cover}
style={{
'background-image': `url(${getImageUrl(form.coverImageUrl || '', {
width: 1600
})})`
}}
>
<Popover content={t('Delete cover')}>
{(triggerRef: (_el: HTMLElement | null) => void) => (
<div
ref={triggerRef}
class={styles.delete}
onClick={() => handleInputChange('coverImageUrl', '')}
>
<Icon name="close-white" />
</div>
)}
</Popover>
</div>
</Show>
</Show>
</div>
<Show when={props.shout.layout === 'image'}>
<EditorSwiper
images={mediaItems()}
onImageChange={handleMediaChange}
onImageDelete={(index) => handleMediaDelete(index)}
onImagesAdd={(value: MediaItem[]) => handleAddMedia(value)}
onImagesSorted={(value) => handleSortedMedia(value)}
/>
</Show>
<Show when={props.shout.layout === 'video'}>
<VideoUploader
video={mediaItems()}
onVideoAdd={(data) => handleAddMedia(data)}
onVideoDelete={(index) => handleMediaDelete(index)}
/>
</Show>
<Show when={props.shout.layout === 'audio'}>
<AudioUploader
audio={mediaItems()}
baseFields={baseAudioFields()}
onAudioAdd={(value) => handleAddMedia(value)}
onAudioChange={handleMediaChange}
onAudioSorted={(value) => handleSortedMedia(value)}
/>
</Show>
</>
</Show>
</div>
)
}
return (
<>
<div class={styles.container}>
@ -289,159 +446,7 @@ export const EditView = (props: Props) => {
</div>
<div class="row">
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
<Show when={props.shout}>
<div class={styles.headingActions}>
<Show when={!isSubtitleVisible() && props.shout.layout !== 'audio'}>
<div class={styles.action} onClick={showSubtitleInput}>
{t('Add subtitle')}
</div>
</Show>
<Show when={!isLeadVisible() && props.shout.layout !== 'audio'}>
<div class={styles.action} onClick={showLeadInput}>
{t('Add intro')}
</div>
</Show>
</div>
<>
<div class={clsx({ [styles.audioHeader]: props.shout.layout === 'audio' })}>
<div class={styles.inputContainer}>
<GrowingTextarea
allowEnterKey={true}
value={(value) => handleTitleInputChange(value)}
class={styles.titleInput}
placeholder={articleTitle()}
initialValue={form.title}
maxLength={MAX_HEADER_LIMIT}
/>
<Show when={formErrors.title}>
<div class={styles.validationError}>{formErrors.title}</div>
</Show>
<Show when={props.shout.layout === 'audio'}>
<div class={styles.additional}>
<input
type="text"
placeholder={t('Artist...')}
class={styles.additionalInput}
value={mediaItems()[0]?.artist || ''}
onChange={(event) => handleBaseFieldsChange('artist', event.target.value)}
/>
<input
type="number"
min="1900"
max={new Date().getFullYear()}
step="1"
class={styles.additionalInput}
placeholder={t('Release date...')}
value={mediaItems()[0]?.date || ''}
onChange={(event) => handleBaseFieldsChange('date', event.target.value)}
/>
<input
type="text"
placeholder={t('Genre...')}
class={styles.additionalInput}
value={mediaItems()[0]?.genre || ''}
onChange={(event) => handleBaseFieldsChange('genre', event.target.value)}
/>
</div>
</Show>
<Show when={props.shout.layout !== 'audio'}>
<Show when={isSubtitleVisible()}>
<GrowingTextarea
textAreaRef={setSubtitleInput}
allowEnterKey={false}
value={(value) => handleInputChange('subtitle', value || '')}
class={styles.subtitleInput}
placeholder={t('Subheader')}
initialValue={form.subtitle || ''}
maxLength={MAX_HEADER_LIMIT}
/>
</Show>
<Show when={isLeadVisible()}>
<MicroEditor
placeholder={t('A short introduction to keep the reader interested')}
content={form.lead}
onChange={(value: string) => handleInputChange('lead', value)}
/>
</Show>
</Show>
</div>
<Show when={props.shout.layout === 'audio'}>
<Show
when={form.coverImageUrl}
fallback={
<DropArea
isSquare={true}
placeholder={t('Add cover')}
description={
<>
{t('min. 1400×1400 pix')}
<br />
{t('jpg, .png, max. 10 mb.')}
</>
}
isMultiply={false}
fileType={'image'}
onUpload={(val) => handleInputChange('coverImageUrl', val[0].url)}
/>
}
>
<div
class={styles.cover}
style={{
'background-image': `url(${getImageUrl(form.coverImageUrl || '', {
width: 1600
})})`
}}
>
<Popover content={t('Delete cover')}>
{(triggerRef: (_el: HTMLElement | null) => void) => (
<div
ref={triggerRef}
class={styles.delete}
onClick={() => handleInputChange('coverImageUrl', '')}
>
<Icon name="close-white" />
</div>
)}
</Popover>
</div>
</Show>
</Show>
</div>
<Show when={props.shout.layout === 'image'}>
<EditorSwiper
images={mediaItems()}
onImageChange={handleMediaChange}
onImageDelete={(index) => handleMediaDelete(index)}
onImagesAdd={(value: MediaItem[]) => handleAddMedia(value)}
onImagesSorted={(value) => handleSortedMedia(value)}
/>
</Show>
<Show when={props.shout.layout === 'video'}>
<VideoUploader
video={mediaItems()}
onVideoAdd={(data) => handleAddMedia(data)}
onVideoDelete={(index) => handleMediaDelete(index)}
/>
</Show>
<Show when={props.shout.layout === 'audio'}>
<AudioUploader
audio={mediaItems()}
baseFields={baseAudioFields()}
onAudioAdd={(value) => handleAddMedia(value)}
onAudioChange={handleMediaChange}
onAudioSorted={(value) => handleSortedMedia(value)}
/>
</Show>
</>
</Show>
</div>
<HeadingActions />
</div>
<Show when={draft()?.id} fallback={<Loading />}>
<EditorComponent
@ -453,9 +458,6 @@ export const EditView = (props: Props) => {
</div>
</form>
</div>
<Show when={props.shout}>
<Panel shoutId={props.shout.id} />
</Show>
<Modal variant="medium" name="inviteCoauthors">
<InviteMembers variant={'coauthors'} title={t('Invite experts')} />

View File

@ -9,7 +9,7 @@ import { byCreated } from '~/utils/sort'
export type LoadMoreItems = Shout[] | Author[] | Reaction[]
type LoadMoreProps = {
loadFunction: (offset: number) => Promise<LoadMoreItems>
loadFunction: (offset: number) => Promise<LoadMoreItems | undefined>
pageSize: number
hidden?: boolean
children: JSX.Element

View File

@ -1,9 +1,8 @@
import { useMatch, useNavigate } from '@solidjs/router'
import { Editor, EditorOptions } from '@tiptap/core'
import { Editor } from '@tiptap/core'
import type { JSX } from 'solid-js'
import { Accessor, createContext, createSignal, useContext } from 'solid-js'
import { SetStoreFunction, createStore } from 'solid-js/store'
import { createTiptapEditor } from 'solid-tiptap'
import { useSnackbar } from '~/context/ui'
import deleteShoutQuery from '~/graphql/mutation/core/article-delete'
import updateShoutQuery from '~/graphql/mutation/core/article-update'
@ -13,7 +12,7 @@ import { useFeed } from '../context/feed'
import { useLocalize } from './localize'
import { useSession } from './session'
type WordCounter = {
export type WordCounter = {
characters: number
words: number
}
@ -49,8 +48,8 @@ export type EditorContextType = {
countWords: (value: WordCounter) => void
setForm: SetStoreFunction<ShoutForm>
setFormErrors: SetStoreFunction<Record<keyof ShoutForm, string>>
editor: Accessor<Editor | undefined>
createEditor: (opts?: Partial<EditorOptions>) => void
editing: Accessor<Editor | undefined>
setEditing: SetStoreFunction<Editor | undefined>
}
export const EditorContext = createContext<EditorContextType>({} as EditorContextType)
@ -84,7 +83,6 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
const matchEdit = useMatch(() => '/edit')
const matchEditSettings = useMatch(() => '/editSettings')
const { client } = useSession()
const [editor, setEditor] = createSignal<Editor | undefined>()
const { addFeed } = useFeed()
const snackbar = useSnackbar()
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
@ -268,17 +266,8 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
}
}
const createEditor = (opts?: Partial<EditorOptions>) => {
if (!opts) return
const old = editor() as Editor
const fresh = createTiptapEditor(() => ({
...old.options,
...opts,
element: opts.element as HTMLElement
}))
old?.destroy()
setEditor(fresh())
}
// current publishing editor instance to connect settings, panel and editor
const [editing, setEditing] = createSignal<Editor | undefined>(undefined)
const actions = {
saveShout,
@ -292,8 +281,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
countWords,
setForm,
setFormErrors,
editor,
createEditor
setEditing
}
const value: EditorContextType = {
@ -301,7 +289,8 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
form,
formErrors,
isEditorPanelVisible,
wordCounter
wordCounter,
editing
}
return <EditorContext.Provider value={value}>{props.children}</EditorContext.Provider>

View File

@ -31,6 +31,40 @@
@include make-col($i, $grid-columns);
}
}
// Добавляем классы для управления порядком колонок
.order#{$infix}-first { order: -1; }
.order#{$infix}-last { order: $grid-columns + 1; }
@for $i from 0 through $grid-columns {
.order#{$infix}-#{$i} { order: $i; }
}
// Добавляем классы для смещения колонок
@for $i from 0 through $grid-columns - 1 {
.offset#{$infix}-#{$i} {
@include make-col-offset($i, $grid-columns);
}
}
}
}
// Добавляем только используемые классы display для разных размеров экрана
@each $breakpoint in map-keys($grid-breakpoints) {
$infix: if($breakpoint == 'xs', '', "-#{$breakpoint}");
@include media-breakpoint-up($breakpoint) {
.d#{$infix}-flex { display: flex !important; }
.d#{$infix}-none { display: none !important; }
}
}
// Добавляем только используемый класс justify-content для разных размеров экрана
@each $breakpoint in map-keys($grid-breakpoints) {
$infix: if($breakpoint == 'xs', '', "-#{$breakpoint}");
@include media-breakpoint-up($breakpoint) {
.justify-content#{$infix}-between { justify-content: space-between !important; }
}
}

View File

@ -2,7 +2,9 @@
@import 'theme';
@import 'grid';
* {
*,
*::before,
*::after {
box-sizing: border-box;
}
@ -170,9 +172,10 @@ button {
background: none;
border: none;
cursor: pointer;
font-family: inherit;
padding: 0;
margin: 0;
font: inherit;
&[disabled] {
cursor: default;
opacity: 0.5 !important;