mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
feat: feedback (#156)
* minimal tiptap * wip * img edit block * wip * fix
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { CodeBlockLowlight as TiptapCodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
|
||||
export const CodeBlockLowlight = TiptapCodeBlockLowlight.extend({
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
lowlight: createLowlight(common),
|
||||
defaultLanguage: null,
|
||||
HTMLAttributes: {
|
||||
class: 'block-node'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default CodeBlockLowlight
|
||||
@@ -0,0 +1 @@
|
||||
export * from './code-block-lowlight'
|
||||
20
web/components/minimal-tiptap/extensions/color/color.ts
Normal file
20
web/components/minimal-tiptap/extensions/color/color.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Color as TiptapColor } from '@tiptap/extension-color'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
|
||||
export const Color = TiptapColor.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
new Plugin({
|
||||
props: {
|
||||
handleKeyDown: (_, event) => {
|
||||
if (event.key === 'Enter') {
|
||||
this.editor.commands.unsetColor()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
1
web/components/minimal-tiptap/extensions/color/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/color/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './color'
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Wrap the horizontal rule in a div element.
|
||||
* Also add a keyboard shortcut to insert a horizontal rule.
|
||||
*/
|
||||
import { HorizontalRule as TiptapHorizontalRule } from '@tiptap/extension-horizontal-rule'
|
||||
|
||||
export const HorizontalRule = TiptapHorizontalRule.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-Alt--': () =>
|
||||
this.editor.commands.insertContent({
|
||||
type: this.name
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default HorizontalRule
|
||||
@@ -0,0 +1 @@
|
||||
export * from './horizontal-rule'
|
||||
@@ -0,0 +1,45 @@
|
||||
import { isNumber, NodeViewProps, NodeViewWrapper } from '@tiptap/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useImageLoad } from '../../../hooks/use-image-load'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ImageViewBlock = ({ editor, node, getPos }: NodeViewProps) => {
|
||||
const imgSize = useImageLoad(node.attrs.src)
|
||||
|
||||
const paddingBottom = useMemo(() => {
|
||||
if (!imgSize.width || !imgSize.height) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (imgSize.height / imgSize.width) * 100
|
||||
}, [imgSize.width, imgSize.height])
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div draggable data-drag-handle>
|
||||
<figure>
|
||||
<div className="relative w-full" style={{ paddingBottom: `${isNumber(paddingBottom) ? paddingBottom : 0}%` }}>
|
||||
<div className="absolute h-full w-full">
|
||||
<div
|
||||
className={cn('relative h-full max-h-full w-full max-w-full rounded transition-all')}
|
||||
style={{
|
||||
boxShadow: editor.state.selection.from === getPos() ? '0 0 0 1px hsl(var(--primary))' : 'none'
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-full max-h-full w-full max-w-full overflow-hidden">
|
||||
<img
|
||||
alt={node.attrs.alt}
|
||||
src={node.attrs.src}
|
||||
className="absolute left-2/4 top-2/4 m-0 h-full max-w-full -translate-x-2/4 -translate-y-2/4 transform object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export { ImageViewBlock }
|
||||
9
web/components/minimal-tiptap/extensions/image/image.ts
Normal file
9
web/components/minimal-tiptap/extensions/image/image.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Image as TiptapImage } from '@tiptap/extension-image'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { ImageViewBlock } from './components/image-view-block'
|
||||
|
||||
export const Image = TiptapImage.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageViewBlock)
|
||||
}
|
||||
})
|
||||
1
web/components/minimal-tiptap/extensions/image/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/image/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './image'
|
||||
8
web/components/minimal-tiptap/extensions/index.ts
Normal file
8
web/components/minimal-tiptap/extensions/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './code-block-lowlight'
|
||||
export * from './color'
|
||||
export * from './horizontal-rule'
|
||||
export * from './image'
|
||||
export * from './link'
|
||||
export * from './selection'
|
||||
export * from './unset-all-marks'
|
||||
export * from './reset-marks-on-enter'
|
||||
1
web/components/minimal-tiptap/extensions/link/index.ts
Normal file
1
web/components/minimal-tiptap/extensions/link/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './link'
|
||||
89
web/components/minimal-tiptap/extensions/link/link.ts
Normal file
89
web/components/minimal-tiptap/extensions/link/link.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import TiptapLink from '@tiptap/extension-link'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import { getMarkRange } from '@tiptap/core'
|
||||
import { Plugin, TextSelection } from '@tiptap/pm/state'
|
||||
|
||||
export const Link = TiptapLink.extend({
|
||||
/*
|
||||
* Determines whether typing next to a link automatically becomes part of the link.
|
||||
* In this case, we dont want any characters to be included as part of the link.
|
||||
*/
|
||||
inclusive: false,
|
||||
|
||||
/*
|
||||
* Match all <a> elements that have an href attribute, except for:
|
||||
* - <a> elements with a data-type attribute set to button
|
||||
* - <a> elements with an href attribute that contains 'javascript:'
|
||||
*/
|
||||
parseHTML() {
|
||||
return [{ tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
},
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'link'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this
|
||||
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
new Plugin({
|
||||
props: {
|
||||
handleKeyDown: (_: EditorView, event: KeyboardEvent) => {
|
||||
const { selection } = editor.state
|
||||
|
||||
/*
|
||||
* Handles the 'Escape' key press when there's a selection within the link.
|
||||
* This will move the cursor to the end of the link.
|
||||
*/
|
||||
if (event.key === 'Escape' && selection.empty !== true) {
|
||||
editor.commands.focus(selection.to, { scrollIntoView: false })
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
handleClick(view, pos) {
|
||||
/*
|
||||
* Marks the entire link when the user clicks on it.
|
||||
*/
|
||||
|
||||
const { schema, doc, tr } = view.state
|
||||
const range = getMarkRange(doc.resolve(pos), schema.marks.link)
|
||||
|
||||
if (!range) {
|
||||
return
|
||||
}
|
||||
|
||||
const { from, to } = range
|
||||
const start = Math.min(from, to)
|
||||
const end = Math.max(from, to)
|
||||
|
||||
if (pos < start || pos > end) {
|
||||
return
|
||||
}
|
||||
|
||||
const $start = doc.resolve(start)
|
||||
const $end = doc.resolve(end)
|
||||
const transaction = tr.setSelection(new TextSelection($start, $end))
|
||||
|
||||
view.dispatch(transaction)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export default Link
|
||||
@@ -0,0 +1 @@
|
||||
export * from './reset-marks-on-enter'
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
export const ResetMarksOnEnter = Extension.create({
|
||||
name: 'resetMarksOnEnter',
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor }) => {
|
||||
if (
|
||||
editor.isActive('bold') ||
|
||||
editor.isActive('italic') ||
|
||||
editor.isActive('strike') ||
|
||||
editor.isActive('underline') ||
|
||||
editor.isActive('code')
|
||||
) {
|
||||
editor.commands.splitBlock({ keepMarks: false })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from './selection'
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
|
||||
export const Selection = Extension.create({
|
||||
name: 'selection',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('selection'),
|
||||
props: {
|
||||
decorations(state) {
|
||||
if (state.selection.empty) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (editor.isFocused === true) {
|
||||
return null
|
||||
}
|
||||
|
||||
return DecorationSet.create(state.doc, [
|
||||
Decoration.inline(state.selection.from, state.selection.to, {
|
||||
class: 'selection'
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export default Selection
|
||||
@@ -0,0 +1 @@
|
||||
export * from './unset-all-marks'
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
export const UnsetAllMarks = Extension.create({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-\\': () => this.editor.commands.unsetAllMarks()
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user