mirror of https://github.com/nocodb/nocodb
github-actions[bot]
6 months ago
committed by
GitHub
209 changed files with 7948 additions and 2694 deletions
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 2.8 KiB |
@ -0,0 +1,150 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import dayjs from 'dayjs' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
size?: 'medium' |
||||||
|
selectedDate?: dayjs.Dayjs | null |
||||||
|
pageDate?: dayjs.Dayjs |
||||||
|
isCellInputField?: boolean |
||||||
|
type: 'date' | 'time' | 'year' | 'month' |
||||||
|
isOpen: boolean |
||||||
|
} |
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), { |
||||||
|
size: 'medium', |
||||||
|
selectedDate: null, |
||||||
|
pageDate: () => dayjs(), |
||||||
|
isCellInputField: false, |
||||||
|
type: 'date', |
||||||
|
isOpen: false, |
||||||
|
}) |
||||||
|
const emit = defineEmits(['update:selectedDate', 'update:pageDate', 'update:selectedWeek']) |
||||||
|
// Page date is the date we use to manage which month/date that is currently being displayed |
||||||
|
const pageDate = useVModel(props, 'pageDate', emit) |
||||||
|
|
||||||
|
const selectedDate = useVModel(props, 'selectedDate', emit) |
||||||
|
|
||||||
|
const { type, isOpen } = toRefs(props) |
||||||
|
|
||||||
|
const localPageDate = ref() |
||||||
|
|
||||||
|
const localSelectedDate = ref() |
||||||
|
|
||||||
|
const pickerType = ref<Props['type'] | undefined>() |
||||||
|
|
||||||
|
const pickerStack = ref<Props['type'][]>([]) |
||||||
|
|
||||||
|
const tempPickerType = computed(() => pickerType.value || type.value) |
||||||
|
|
||||||
|
const handleUpdatePickerType = (value?: Props['type']) => { |
||||||
|
if (value) { |
||||||
|
pickerType.value = value |
||||||
|
pickerStack.value.push(value) |
||||||
|
} else { |
||||||
|
if (pickerStack.value.length > 1) { |
||||||
|
pickerStack.value.pop() |
||||||
|
const lastPicker = pickerStack.value.pop() |
||||||
|
pickerType.value = lastPicker |
||||||
|
} else { |
||||||
|
pickerStack.value = [] |
||||||
|
pickerType.value = type.value |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const localStatePageDate = computed({ |
||||||
|
get: () => { |
||||||
|
if (localPageDate.value) { |
||||||
|
return localPageDate.value |
||||||
|
} |
||||||
|
return pageDate.value |
||||||
|
}, |
||||||
|
set: (value) => { |
||||||
|
pageDate.value = value |
||||||
|
localPageDate.value = value |
||||||
|
emit('update:pageDate', value) |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const localStateSelectedDate = computed({ |
||||||
|
get: () => { |
||||||
|
if (localSelectedDate.value) { |
||||||
|
return localSelectedDate.value |
||||||
|
} |
||||||
|
return pageDate.value |
||||||
|
}, |
||||||
|
set: (value: dayjs.Dayjs) => { |
||||||
|
if (!value.isValid()) return |
||||||
|
|
||||||
|
if (pickerType.value === type.value) { |
||||||
|
localPageDate.value = value |
||||||
|
emit('update:selectedDate', value) |
||||||
|
localSelectedDate.value = undefined |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if (['date', 'month'].includes(type.value)) { |
||||||
|
if (pickerType.value === 'year') { |
||||||
|
localSelectedDate.value = dayjs(localPageDate.value ?? localSelectedDate.value ?? selectedDate.value ?? dayjs()).year( |
||||||
|
+value.format('YYYY'), |
||||||
|
) |
||||||
|
} |
||||||
|
if (type.value !== 'month' && pickerType.value === 'month') { |
||||||
|
localSelectedDate.value = dayjs(localPageDate.value ?? localSelectedDate.value ?? selectedDate.value ?? dayjs()).month( |
||||||
|
+value.format('MM') - 1, |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
localPageDate.value = localSelectedDate.value |
||||||
|
|
||||||
|
handleUpdatePickerType() |
||||||
|
} |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
watch(isOpen, (next) => { |
||||||
|
if (!next) { |
||||||
|
pickerType.value = type.value |
||||||
|
localPageDate.value = undefined |
||||||
|
localSelectedDate.value = undefined |
||||||
|
pickerStack.value = [] |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
onUnmounted(() => { |
||||||
|
pickerType.value = type.value |
||||||
|
localPageDate.value = undefined |
||||||
|
localSelectedDate.value = undefined |
||||||
|
pickerStack.value = [] |
||||||
|
}) |
||||||
|
onMounted(() => { |
||||||
|
localPageDate.value = undefined |
||||||
|
localSelectedDate.value = undefined |
||||||
|
pickerStack.value = [] |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<NcDateWeekSelector |
||||||
|
v-if="tempPickerType === 'date'" |
||||||
|
v-model:page-date="localStatePageDate" |
||||||
|
v-model:selected-date="localStateSelectedDate" |
||||||
|
:picker-type="pickerType" |
||||||
|
:is-monday-first="false" |
||||||
|
is-cell-input-field |
||||||
|
size="medium" |
||||||
|
@update:picker-type="handleUpdatePickerType" |
||||||
|
/> |
||||||
|
<NcMonthYearSelector |
||||||
|
v-if="['month', 'year'].includes(tempPickerType)" |
||||||
|
v-model:page-date="localStatePageDate" |
||||||
|
v-model:selected-date="localStateSelectedDate" |
||||||
|
:picker-type="pickerType" |
||||||
|
:is-year-picker="tempPickerType === 'year'" |
||||||
|
is-cell-input-field |
||||||
|
size="medium" |
||||||
|
@update:picker-type="handleUpdatePickerType" |
||||||
|
/> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss" scoped></style> |
@ -0,0 +1,104 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import dayjs from 'dayjs' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
selectedDate: dayjs.Dayjs | null |
||||||
|
is12hrFormat?: boolean |
||||||
|
isMinGranularityPicker?: boolean |
||||||
|
minGranularity?: number |
||||||
|
isOpen?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), { |
||||||
|
selectedDate: null, |
||||||
|
is12hrFormat: false, |
||||||
|
isMinGranularityPicker: false, |
||||||
|
minGranularity: 30, |
||||||
|
isOpen: false, |
||||||
|
}) |
||||||
|
const emit = defineEmits(['update:selectedDate']) |
||||||
|
|
||||||
|
const pageDate = ref<dayjs.Dayjs>(dayjs()) |
||||||
|
|
||||||
|
const selectedDate = useVModel(props, 'selectedDate', emit) |
||||||
|
|
||||||
|
const { is12hrFormat, isMinGranularityPicker, minGranularity, isOpen } = toRefs(props) |
||||||
|
|
||||||
|
const timeOptionsWrapperRef = ref<HTMLDivElement>() |
||||||
|
|
||||||
|
const compareTime = (date1: dayjs.Dayjs, date2: dayjs.Dayjs) => { |
||||||
|
if (!date1 || !date2) return false |
||||||
|
|
||||||
|
return date1.format('HH:mm') === date2.format('HH:mm') |
||||||
|
} |
||||||
|
|
||||||
|
const handleSelectTime = (time: dayjs.Dayjs) => { |
||||||
|
pageDate.value = dayjs().set('hour', time.get('hour')).set('minute', time.get('minute')) |
||||||
|
|
||||||
|
selectedDate.value = pageDate.value |
||||||
|
|
||||||
|
// emit('update:selectedDate', pageDate.value) |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: 12hr time format & regular time picker |
||||||
|
const timeOptions = computed(() => { |
||||||
|
return Array.from({ length: is12hrFormat.value ? 12 : 24 }).flatMap((_, h) => { |
||||||
|
return (isMinGranularityPicker.value ? [0, minGranularity.value] : Array.from({ length: 60 })).map((_m, m) => { |
||||||
|
const time = dayjs() |
||||||
|
.set('hour', h) |
||||||
|
.set('minute', isMinGranularityPicker.value ? (_m as number) : m) |
||||||
|
|
||||||
|
return time |
||||||
|
}) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
const handleAutoScroll = (behavior: ScrollBehavior = 'instant') => { |
||||||
|
if (!timeOptionsWrapperRef.value || !selectedDate.value) return |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
const timeEl = timeOptionsWrapperRef.value?.querySelector( |
||||||
|
`[data-testid="time-option-${selectedDate.value?.format('HH:mm')}"]`, |
||||||
|
) |
||||||
|
|
||||||
|
timeEl?.scrollIntoView({ behavior, block: 'center' }) |
||||||
|
}, 50) |
||||||
|
} |
||||||
|
|
||||||
|
watch([selectedDate, isOpen], () => { |
||||||
|
if (timeOptionsWrapperRef.value && isOpen.value && selectedDate.value) { |
||||||
|
handleAutoScroll() |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
handleAutoScroll() |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="flex flex-col max-w-[350px]"> |
||||||
|
<div v-if="isMinGranularityPicker" ref="timeOptionsWrapperRef" class="h-[180px] overflow-y-auto nc-scrollbar-thin"> |
||||||
|
<div |
||||||
|
v-for="time of timeOptions" |
||||||
|
:key="time.format('HH:mm')" |
||||||
|
class="hover:bg-gray-100 py-1 px-3 text-sm text-gray-600 font-weight-500 text-center cursor-pointer" |
||||||
|
:class="{ |
||||||
|
'nc-selected bg-gray-100': selectedDate && compareTime(time, selectedDate), |
||||||
|
}" |
||||||
|
:data-testid="`time-option-${time.format('HH:mm')}`" |
||||||
|
@click="handleSelectTime(time)" |
||||||
|
> |
||||||
|
{{ time.format('HH:mm') }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div v-else></div> |
||||||
|
<div class="px-2 py-1 box-border flex items-center justify-center"> |
||||||
|
<NcButton :tabindex="-1" class="!h-7" size="small" type="secondary" @click="handleSelectTime(dayjs())"> |
||||||
|
<span class="text-small"> {{ $t('general.now') }} </span> |
||||||
|
</NcButton> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss" scoped></style> |
@ -0,0 +1,380 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import StarterKit from '@tiptap/starter-kit' |
||||||
|
import { EditorContent, useEditor } from '@tiptap/vue-3' |
||||||
|
import TurndownService from 'turndown' |
||||||
|
import { marked } from 'marked' |
||||||
|
import { generateJSON } from '@tiptap/html' |
||||||
|
import Underline from '@tiptap/extension-underline' |
||||||
|
import Placeholder from '@tiptap/extension-placeholder' |
||||||
|
import { Link } from '~/helpers/dbTiptapExtensions/links' |
||||||
|
|
||||||
|
const props = withDefaults( |
||||||
|
defineProps<{ |
||||||
|
hideOptions?: boolean |
||||||
|
value?: string | null |
||||||
|
readOnly?: boolean |
||||||
|
syncValueChange?: boolean |
||||||
|
autofocus?: boolean |
||||||
|
placeholder?: string |
||||||
|
renderAsText?: boolean |
||||||
|
}>(), |
||||||
|
{ |
||||||
|
hideOptions: true, |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
const emits = defineEmits(['update:value', 'focus', 'blur', 'save']) |
||||||
|
|
||||||
|
const isGrid = inject(IsGridInj, ref(false)) |
||||||
|
|
||||||
|
const isFocused = ref(false) |
||||||
|
|
||||||
|
const keys = useMagicKeys() |
||||||
|
|
||||||
|
const turndownService = new TurndownService({}) |
||||||
|
|
||||||
|
turndownService.addRule('lineBreak', { |
||||||
|
filter: (node) => { |
||||||
|
return node.nodeName === 'BR' |
||||||
|
}, |
||||||
|
replacement: () => { |
||||||
|
return '<br />' |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
turndownService.addRule('strikethrough', { |
||||||
|
filter: ['s'], |
||||||
|
replacement: (content) => { |
||||||
|
return `~${content}~` |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
turndownService.keep(['u', 'del']) |
||||||
|
|
||||||
|
const editorDom = ref<HTMLElement | null>(null) |
||||||
|
|
||||||
|
const richTextLinkOptionRef = ref<HTMLElement | null>(null) |
||||||
|
|
||||||
|
const vModel = useVModel(props, 'value', emits, { defaultValue: '' }) |
||||||
|
|
||||||
|
const tiptapExtensions = [ |
||||||
|
StarterKit.configure({ |
||||||
|
heading: false, |
||||||
|
}), |
||||||
|
Underline, |
||||||
|
Link, |
||||||
|
Placeholder.configure({ |
||||||
|
emptyEditorClass: 'is-editor-empty', |
||||||
|
placeholder: props.placeholder, |
||||||
|
}), |
||||||
|
] |
||||||
|
|
||||||
|
const editor = useEditor({ |
||||||
|
extensions: tiptapExtensions, |
||||||
|
onUpdate: ({ editor }) => { |
||||||
|
const markdown = turndownService |
||||||
|
.turndown(editor.getHTML().replaceAll(/<p><\/p>/g, '<br />')) |
||||||
|
.replaceAll(/\n\n<br \/>\n\n/g, '<br>\n\n') |
||||||
|
vModel.value = markdown === '<br />' ? '' : markdown |
||||||
|
}, |
||||||
|
editable: !props.readOnly, |
||||||
|
autofocus: props.autofocus, |
||||||
|
onFocus: () => { |
||||||
|
isFocused.value = true |
||||||
|
emits('focus') |
||||||
|
}, |
||||||
|
onBlur: (e) => { |
||||||
|
if ( |
||||||
|
!(e?.event?.relatedTarget as HTMLElement)?.closest('.comment-bubble-menu, .nc-comment-rich-editor, .nc-rich-text-comment') |
||||||
|
) { |
||||||
|
isFocused.value = false |
||||||
|
emits('blur') |
||||||
|
} |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const setEditorContent = (contentMd: any, focusEndOfDoc?: boolean) => { |
||||||
|
if (!editor.value) return |
||||||
|
|
||||||
|
const selection = editor.value.view.state.selection |
||||||
|
|
||||||
|
const contentHtml = contentMd ? marked.parse(contentMd) : '<p></p>' |
||||||
|
|
||||||
|
const content = generateJSON(contentHtml, tiptapExtensions) |
||||||
|
|
||||||
|
editor.value.chain().setContent(content).setTextSelection(selection.to).run() |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
if (focusEndOfDoc) { |
||||||
|
const docSize = editor.value!.state.doc.nodeSize |
||||||
|
|
||||||
|
editor.value |
||||||
|
?.chain() |
||||||
|
.setTextSelection(docSize - 1) |
||||||
|
.run() |
||||||
|
} |
||||||
|
|
||||||
|
;(editor.value!.state as any).history$.prevRanges = null |
||||||
|
;(editor.value!.state as any).history$.done.eventCount = 0 |
||||||
|
}, 100) |
||||||
|
} |
||||||
|
|
||||||
|
const onFocusWrapper = () => { |
||||||
|
if (!props.readOnly && !keys.shift.value) { |
||||||
|
editor.value?.chain().focus().run() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (props.syncValueChange) { |
||||||
|
watch([vModel, editor], () => { |
||||||
|
setEditorContent(vModel.value) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
useEventListener( |
||||||
|
editorDom, |
||||||
|
'focusout', |
||||||
|
(e: FocusEvent) => { |
||||||
|
const targetEl = e?.relatedTarget as HTMLElement |
||||||
|
if ( |
||||||
|
targetEl?.classList?.contains('tiptap') || |
||||||
|
!targetEl?.closest('.comment-bubble-menu, .tippy-content, .nc-comment-rich-editor') |
||||||
|
) { |
||||||
|
isFocused.value = false |
||||||
|
emits('blur') |
||||||
|
} |
||||||
|
}, |
||||||
|
true, |
||||||
|
) |
||||||
|
useEventListener( |
||||||
|
richTextLinkOptionRef, |
||||||
|
'focusout', |
||||||
|
(e: FocusEvent) => { |
||||||
|
const targetEl = e?.relatedTarget as HTMLElement |
||||||
|
if (!targetEl && (e.target as HTMLElement)?.closest('.comment-bubble-menu, .tippy-content, .nc-comment-rich-editor')) return |
||||||
|
|
||||||
|
if (!targetEl?.closest('.comment-bubble-menu, .tippy-content, .nc-comment-rich-editor')) { |
||||||
|
isFocused.value = false |
||||||
|
emits('blur') |
||||||
|
} |
||||||
|
}, |
||||||
|
true, |
||||||
|
) |
||||||
|
onClickOutside(editorDom, (e) => { |
||||||
|
if (!isFocused.value) return |
||||||
|
|
||||||
|
const targetEl = e?.target as HTMLElement |
||||||
|
|
||||||
|
if (!targetEl?.closest('.tippy-content, .comment-bubble-menu, .nc-comment-rich-editor')) { |
||||||
|
isFocused.value = false |
||||||
|
emits('blur') |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const triggerSaveFromList = ref(false) |
||||||
|
|
||||||
|
const emitSave = (event: KeyboardEvent) => { |
||||||
|
if (editor.value) { |
||||||
|
if (triggerSaveFromList.value) { |
||||||
|
// If Enter was pressed in the list, do not emit save |
||||||
|
triggerSaveFromList.value = false |
||||||
|
} else { |
||||||
|
if (editor.value.isActive('bulletList') || editor.value.isActive('orderedList')) { |
||||||
|
event.stopPropagation() |
||||||
|
} else { |
||||||
|
emits('save') |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleEnterDown = (event: KeyboardEvent) => { |
||||||
|
const isListsActive = editor.value?.isActive('bulletList') || editor.value?.isActive('orderedList') |
||||||
|
if (isListsActive) { |
||||||
|
triggerSaveFromList.value = true |
||||||
|
setTimeout(() => { |
||||||
|
triggerSaveFromList.value = false |
||||||
|
}, 1000) |
||||||
|
} else { |
||||||
|
emitSave(event) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleKeyPress = (event: KeyboardEvent) => { |
||||||
|
if (event.altKey && event.key === 'Enter') { |
||||||
|
event.stopPropagation() |
||||||
|
} else if (event.shiftKey && event.key === 'Enter') { |
||||||
|
event.stopPropagation() |
||||||
|
} else if (event.key === 'Enter') { |
||||||
|
handleEnterDown(event) |
||||||
|
} else if (event.key === 'Escape') { |
||||||
|
isFocused.value = false |
||||||
|
emits('blur') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
defineExpose({ |
||||||
|
setEditorContent, |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div |
||||||
|
:class="{ |
||||||
|
'readonly': readOnly, |
||||||
|
'nc-rich-text-grid': isGrid, |
||||||
|
}" |
||||||
|
:tabindex="1" |
||||||
|
class="nc-rich-text-comment flex flex-col w-full h-full" |
||||||
|
@focus="onFocusWrapper" |
||||||
|
> |
||||||
|
<div v-if="renderAsText" class="truncate"> |
||||||
|
<span v-if="editor"> {{ editor?.getText() ?? '' }}</span> |
||||||
|
</div> |
||||||
|
<template v-else> |
||||||
|
<CellRichTextLinkOptions |
||||||
|
v-if="editor" |
||||||
|
ref="richTextLinkOptionRef" |
||||||
|
:editor="editor" |
||||||
|
:is-form-field="true" |
||||||
|
@blur="isFocused = false" |
||||||
|
/> |
||||||
|
|
||||||
|
<EditorContent |
||||||
|
ref="editorDom" |
||||||
|
:editor="editor" |
||||||
|
class="flex flex-col nc-comment-rich-editor px-1.5 w-full scrollbar-thin scrollbar-thumb-gray-200 nc-truncate scrollbar-track-transparent" |
||||||
|
@keydown.stop="handleKeyPress" |
||||||
|
/> |
||||||
|
|
||||||
|
<div v-if="!hideOptions" class="flex justify-between px-2 py-2 items-center"> |
||||||
|
<LazySmartsheetExpandedFormRichTextOptions :editor="editor" class="!bg-transparent" /> |
||||||
|
<NcButton |
||||||
|
v-e="['a:row-expand:comment:save']" |
||||||
|
:disabled="!vModel?.length" |
||||||
|
class="!disabled:bg-gray-100 !h-7 !w-7 !shadow-none" |
||||||
|
size="xsmall" |
||||||
|
@click="emits('save')" |
||||||
|
> |
||||||
|
<GeneralIcon icon="send" /> |
||||||
|
</NcButton> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"> |
||||||
|
.nc-rich-text-comment { |
||||||
|
.readonly { |
||||||
|
.nc-comment-rich-editor { |
||||||
|
.ProseMirror { |
||||||
|
resize: none; |
||||||
|
white-space: pre-line; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
.nc-comment-rich-editor { |
||||||
|
&.nc-truncate { |
||||||
|
.tiptap.ProseMirror { |
||||||
|
display: -webkit-box; |
||||||
|
max-width: 100%; |
||||||
|
outline: none; |
||||||
|
-webkit-box-orient: vertical; |
||||||
|
word-break: break-word; |
||||||
|
} |
||||||
|
&.nc-line-clamp-1 .tiptap.ProseMirror { |
||||||
|
-webkit-line-clamp: 1; |
||||||
|
} |
||||||
|
&.nc-line-clamp-2 .tiptap.ProseMirror { |
||||||
|
-webkit-line-clamp: 2; |
||||||
|
} |
||||||
|
&.nc-line-clamp-3 .tiptap.ProseMirror { |
||||||
|
-webkit-line-clamp: 3; |
||||||
|
} |
||||||
|
&.nc-line-clamp-4 .tiptap.ProseMirror { |
||||||
|
-webkit-line-clamp: 4; |
||||||
|
} |
||||||
|
} |
||||||
|
.tiptap p.is-editor-empty:first-child::before { |
||||||
|
color: #9aa2af; |
||||||
|
content: attr(data-placeholder); |
||||||
|
float: left; |
||||||
|
height: 0; |
||||||
|
pointer-events: none; |
||||||
|
} |
||||||
|
|
||||||
|
.ProseMirror { |
||||||
|
@apply flex-grow !border-0 rounded-lg; |
||||||
|
caret-color: #3366ff; |
||||||
|
} |
||||||
|
|
||||||
|
p { |
||||||
|
@apply !m-0; |
||||||
|
} |
||||||
|
|
||||||
|
.ProseMirror-focused { |
||||||
|
// remove all border |
||||||
|
outline: none; |
||||||
|
} |
||||||
|
|
||||||
|
ul { |
||||||
|
li { |
||||||
|
@apply ml-4; |
||||||
|
list-style-type: disc; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
ol { |
||||||
|
@apply !pl-4; |
||||||
|
li { |
||||||
|
list-style-type: decimal; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
ul, |
||||||
|
ol { |
||||||
|
@apply !my-0; |
||||||
|
} |
||||||
|
|
||||||
|
// Pre tag is the parent wrapper for Code block |
||||||
|
pre { |
||||||
|
border-color: #d0d5dd; |
||||||
|
border: 1px; |
||||||
|
color: black; |
||||||
|
font-family: 'JetBrainsMono', monospace; |
||||||
|
padding: 1rem; |
||||||
|
border-radius: 0.5rem; |
||||||
|
@apply overflow-auto mt-3 bg-gray-100; |
||||||
|
|
||||||
|
code { |
||||||
|
@apply !px-0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
code { |
||||||
|
@apply rounded-md px-2 py-1 bg-gray-100; |
||||||
|
color: inherit; |
||||||
|
font-size: 0.8rem; |
||||||
|
} |
||||||
|
|
||||||
|
blockquote { |
||||||
|
border-left: 3px solid #d0d5dd; |
||||||
|
padding: 0 1em; |
||||||
|
color: #666; |
||||||
|
margin: 1em 0; |
||||||
|
font-style: italic; |
||||||
|
} |
||||||
|
|
||||||
|
hr { |
||||||
|
@apply !border-gray-300; |
||||||
|
border: 0; |
||||||
|
border-top: 1px solid #ccc; |
||||||
|
margin: 1.5em 0; |
||||||
|
} |
||||||
|
|
||||||
|
pre { |
||||||
|
height: fit-content; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,215 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import type { Editor } from '@tiptap/vue-3' |
||||||
|
import MdiFormatStrikeThrough from '~icons/mdi/format-strikethrough' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
editor: Editor | undefined |
||||||
|
} |
||||||
|
const props = withDefaults(defineProps<Props>(), {}) |
||||||
|
|
||||||
|
const { appInfo } = useGlobal() |
||||||
|
|
||||||
|
const { editor } = toRefs(props) |
||||||
|
|
||||||
|
const cmdOrCtrlKey = computed(() => { |
||||||
|
return isMac() ? '⌘' : 'CTRL' |
||||||
|
}) |
||||||
|
|
||||||
|
const shiftKey = computed(() => { |
||||||
|
return isMac() ? '⇧' : 'Shift' |
||||||
|
}) |
||||||
|
|
||||||
|
const tabIndex = computed(() => { |
||||||
|
return -1 |
||||||
|
}) |
||||||
|
|
||||||
|
const onToggleLink = () => { |
||||||
|
if (!editor.value) return |
||||||
|
|
||||||
|
const activeNode = editor.value?.state?.selection?.$from?.nodeBefore || editor.value?.state?.selection?.$from?.nodeAfter |
||||||
|
|
||||||
|
const isLinkMarkedStoredInEditor = editor.value?.state?.storedMarks?.some((mark: any) => mark.type.name === 'link') |
||||||
|
|
||||||
|
const isActiveNodeMarkActive = activeNode?.marks?.some((mark: any) => mark.type.name === 'link') || isLinkMarkedStoredInEditor |
||||||
|
|
||||||
|
if (isActiveNodeMarkActive) { |
||||||
|
editor.value.chain().focus().unsetLink().run() |
||||||
|
} else { |
||||||
|
if (editor.value?.state.selection.empty) { |
||||||
|
editor |
||||||
|
.value!.chain() |
||||||
|
.focus() |
||||||
|
.insertContent(' ') |
||||||
|
.setTextSelection({ from: editor.value?.state.selection.$from.pos, to: editor.value.state.selection.$from.pos + 1 }) |
||||||
|
.toggleLink({ |
||||||
|
href: '', |
||||||
|
}) |
||||||
|
.setTextSelection({ from: editor.value?.state.selection.$from.pos, to: editor.value.state.selection.$from.pos + 1 }) |
||||||
|
.deleteSelection() |
||||||
|
.run() |
||||||
|
} else { |
||||||
|
editor |
||||||
|
.value!.chain() |
||||||
|
.focus() |
||||||
|
.setLink({ |
||||||
|
href: '', |
||||||
|
}) |
||||||
|
.selectTextblockEnd() |
||||||
|
.run() |
||||||
|
} |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
const linkInput = document.querySelector('.nc-text-area-rich-link-option-input') |
||||||
|
if (linkInput) { |
||||||
|
;(linkInput as any).focus() |
||||||
|
} |
||||||
|
}, 100) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const newMentionNode = () => { |
||||||
|
editor.value?.commands.insertContent('@') |
||||||
|
editor.value?.chain().focus().run() |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="comment-bubble-menu bg-transparent flex-row rounded-lg flex"> |
||||||
|
<NcTooltip> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.bold') }} |
||||||
|
</div> |
||||||
|
<div class="text-xs">{{ cmdOrCtrlKey }} B</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
:class="{ 'is-active': editor?.isActive('bold') }" |
||||||
|
:tabindex="tabIndex" |
||||||
|
class="!h-7 !w-7 !hover:bg-gray-200" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click="editor?.chain().focus().toggleBold().run()" |
||||||
|
> |
||||||
|
<GeneralIcon icon="bold" /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<NcTooltip :disabled="editor?.isActive('italic')"> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.italic') }} |
||||||
|
</div> |
||||||
|
<div>{{ cmdOrCtrlKey }} I</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
:class="{ 'is-active': editor?.isActive('italic') }" |
||||||
|
:tabindex="tabIndex" |
||||||
|
class="!h-7 !w-7 !hover:bg-gray-200" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click=";(editor?.chain().focus() as any).toggleItalic().run()" |
||||||
|
> |
||||||
|
<GeneralIcon icon="italic" /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<NcTooltip> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.underline') }} |
||||||
|
</div> |
||||||
|
<div>{{ cmdOrCtrlKey }} U</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<NcButton |
||||||
|
:class="{ 'is-active': editor?.isActive('underline') }" |
||||||
|
:tabindex="tabIndex" |
||||||
|
class="!h-7 !w-7 !hover:bg-gray-200" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click="editor?.chain().focus().toggleUnderline().run()" |
||||||
|
> |
||||||
|
<GeneralIcon icon="underline" /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<NcTooltip> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.strike') }} |
||||||
|
</div> |
||||||
|
<div>{{ shiftKey }} {{ cmdOrCtrlKey }} S</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
:class="{ 'is-active': editor?.isActive('strike') }" |
||||||
|
:tabindex="tabIndex" |
||||||
|
class="!h-7 !w-7 !hover:bg-gray-200" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click="editor?.chain().focus().toggleStrike().run()" |
||||||
|
> |
||||||
|
<GeneralIcon icon="strike" /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<NcTooltip> |
||||||
|
<template #title> {{ $t('general.link') }}</template> |
||||||
|
<NcButton |
||||||
|
:class="{ 'is-active': editor?.isActive('link') }" |
||||||
|
:tabindex="tabIndex" |
||||||
|
class="!h-7 !w-7 !hover:bg-gray-200" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click="onToggleLink" |
||||||
|
> |
||||||
|
<GeneralIcon icon="link2"></GeneralIcon> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
<NcTooltip v-if="appInfo.ee"> |
||||||
|
<template #title> |
||||||
|
<div class="flex flex-col items-center"> |
||||||
|
<div> |
||||||
|
{{ $t('labels.mention') }} |
||||||
|
</div> |
||||||
|
<div>@</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<NcButton |
||||||
|
:class="{ 'is-active': editor?.isActive('suggestions') }" |
||||||
|
:tabindex="tabIndex" |
||||||
|
class="!h-7 !w-7 !hover:bg-gray-200" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click="newMentionNode" |
||||||
|
> |
||||||
|
<GeneralIcon icon="atSign" /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss" scoped> |
||||||
|
.comment-bubble-menu { |
||||||
|
@apply !border-none; |
||||||
|
|
||||||
|
.nc-button.is-active { |
||||||
|
@apply text-brand-500; |
||||||
|
outline: 1px; |
||||||
|
} |
||||||
|
.ant-select-selector { |
||||||
|
@apply !rounded-md; |
||||||
|
} |
||||||
|
.ant-select-selector .ant-select-selection-item { |
||||||
|
@apply !text-xs; |
||||||
|
} |
||||||
|
.ant-btn-loading-icon { |
||||||
|
@apply pb-0.5; |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
After Width: | Height: | Size: 267 KiB |
After Width: | Height: | Size: 160 KiB |
After Width: | Height: | Size: 145 KiB |
After Width: | Height: | Size: 180 KiB |
@ -0,0 +1,89 @@ |
|||||||
|
import { |
||||||
|
Body, |
||||||
|
Controller, |
||||||
|
Delete, |
||||||
|
Get, |
||||||
|
HttpCode, |
||||||
|
Param, |
||||||
|
Patch, |
||||||
|
Post, |
||||||
|
Query, |
||||||
|
Req, |
||||||
|
UseGuards, |
||||||
|
} from '@nestjs/common'; |
||||||
|
import { GlobalGuard } from '~/guards/global/global.guard'; |
||||||
|
import { PagedResponseImpl } from '~/helpers/PagedResponse'; |
||||||
|
import { CommentsService } from '~/services/comments.service'; |
||||||
|
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; |
||||||
|
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; |
||||||
|
import { NcRequest } from '~/interface/config'; |
||||||
|
|
||||||
|
@Controller() |
||||||
|
@UseGuards(MetaApiLimiterGuard, GlobalGuard) |
||||||
|
export class CommentsController { |
||||||
|
constructor(private readonly commentsService: CommentsService) {} |
||||||
|
|
||||||
|
@Get(['/api/v1/db/meta/comments', '/api/v2/meta/comments']) |
||||||
|
@Acl('commentList') |
||||||
|
async commentList(@Req() req: any) { |
||||||
|
return new PagedResponseImpl( |
||||||
|
await this.commentsService.commentList({ query: req.query }), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
@Post(['/api/v1/db/meta/comments', '/api/v2/meta/comments']) |
||||||
|
@HttpCode(200) |
||||||
|
@Acl('commentRow') |
||||||
|
async commentRow(@Req() req: NcRequest, @Body() body: any) { |
||||||
|
return await this.commentsService.commentRow({ |
||||||
|
user: req.user, |
||||||
|
body: body, |
||||||
|
req, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
@Delete([ |
||||||
|
'/api/v1/db/meta/comment/:commentId', |
||||||
|
'/api/v2/meta/comment/:commentId', |
||||||
|
]) |
||||||
|
@Acl('commentDelete') |
||||||
|
async commentDelete( |
||||||
|
@Req() req: NcRequest, |
||||||
|
@Param('commentId') commentId: string, |
||||||
|
) { |
||||||
|
return await this.commentsService.commentDelete({ |
||||||
|
commentId, |
||||||
|
user: req.user, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
@Patch([ |
||||||
|
'/api/v1/db/meta/comment/:commentId', |
||||||
|
'/api/v2/meta/comment/:commentId', |
||||||
|
]) |
||||||
|
@Acl('commentUpdate') |
||||||
|
async commentUpdate( |
||||||
|
@Param('commentId') commentId: string, |
||||||
|
@Req() req: any, |
||||||
|
@Body() body: any, |
||||||
|
) { |
||||||
|
return await this.commentsService.commentUpdate({ |
||||||
|
commentId: commentId, |
||||||
|
user: req.user, |
||||||
|
body: body, |
||||||
|
req, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
@Get(['/api/v1/db/meta/comments/count', '/api/v2/meta/comments/count']) |
||||||
|
@Acl('commentsCount') |
||||||
|
async commentsCount( |
||||||
|
@Query('fk_model_id') fk_model_id: string, |
||||||
|
@Query('ids') ids: string[], |
||||||
|
) { |
||||||
|
return await this.commentsService.commentsCount({ |
||||||
|
fk_model_id, |
||||||
|
ids, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue