gridfix+editor-wip
This commit is contained in:
parent
6551263fe8
commit
8ba69a5f7f
18
prompt-20steps.txt
Normal file
18
prompt-20steps.txt
Normal 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.
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -30,7 +30,7 @@ export const Panel = (props: Props) => {
|
|||
saveShout,
|
||||
saveDraft,
|
||||
publishShout,
|
||||
editor
|
||||
editing: editor
|
||||
} = useEditorContext()
|
||||
|
||||
let containerRef: HTMLElement | undefined
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')} />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue
Block a user