diff --git a/packages/presentation/src/components/markup/Mark.svelte b/packages/presentation/src/components/markup/Mark.svelte index e2f47d1c25..7bb23ecae5 100644 --- a/packages/presentation/src/components/markup/Mark.svelte +++ b/packages/presentation/src/components/markup/Mark.svelte @@ -56,7 +56,12 @@ {:else if mark.type === MarkupMarkType.em} {:else if mark.type === MarkupMarkType.link} - + {:else if mark.type === MarkupMarkType.strike} diff --git a/packages/text/src/marks/colors.ts b/packages/text/src/marks/colors.ts index d228507a2d..a29f83a49f 100644 --- a/packages/text/src/marks/colors.ts +++ b/packages/text/src/marks/colors.ts @@ -135,7 +135,7 @@ export const TextColor = Extension.create({ unsetTextColor: () => ({ chain }) => { - return chain().setMark('textStyle', { color: null }).removeEmptyTextStyle().run() + return chain().unsetMark('textStyle').run() } } } diff --git a/packages/ui/src/popups.ts b/packages/ui/src/popups.ts index 512d86aace..b03bc0f664 100644 --- a/packages/ui/src/popups.ts +++ b/packages/ui/src/popups.ts @@ -92,12 +92,13 @@ export function showPopup ( overlay: boolean fixed?: boolean refId?: string + id?: string } = { category: 'popup', overlay: true } ): PopupResult { - const id = `${popupId++}` + const id = options?.id ?? `${popupId++}` const closePopupOp = (): void => { modalStore.update((popups) => { const pos = popups.findIndex((p) => (p as CompAndProps).id === id && p.type === 'popup') @@ -107,6 +108,7 @@ export function showPopup ( return popups }) } + closePopupOp() const _element = element instanceof HTMLElement ? getPopupPositionElement(element) : element const data: Omit = { id, diff --git a/plugins/controlled-documents/src/utils.ts b/plugins/controlled-documents/src/utils.ts index ae89a37a65..f399e2ad46 100644 --- a/plugins/controlled-documents/src/utils.ts +++ b/plugins/controlled-documents/src/utils.ts @@ -872,12 +872,16 @@ export async function getFirstRank ( /** * @public */ -export function getEffectiveDocUpdate (): DocumentUpdate { - return { - state: DocumentState.Effective, - effectiveDate: Date.now(), - controlledState: undefined - } +export function getEffectiveDocUpdates (): DocumentUpdate[] { + return [ + { + state: DocumentState.Effective, + effectiveDate: Date.now() + }, + { + $unset: { controlledState: true } + } + ] } /** diff --git a/plugins/text-editor-resources/src/components/extension/colors.ts b/plugins/text-editor-resources/src/components/extension/colors.ts index cac7e5417c..ec48f1d446 100644 --- a/plugins/text-editor-resources/src/components/extension/colors.ts +++ b/plugins/text-editor-resources/src/components/extension/colors.ts @@ -50,7 +50,7 @@ const palette = { colorSpec('red', 'bg') ], text: [ - { color: 'var(--theme-text-color-primary)' }, + { color: 'var(--theme-text-primary-color)' }, colorSpec('gray'), colorSpec('brown'), colorSpec('orange'), @@ -65,33 +65,55 @@ const palette = { export async function openBackgroundColorOptions (editor: Editor, event: MouseEvent): Promise { await new Promise((resolve) => { - showPopup(ColorPicker, { palette: palette.background }, getEventPositionElement(event), (val) => { - const color: string | undefined = val?.color - if (color === undefined) return + showPopup( + ColorPicker, + { palette: palette.background, id: 'text-editor-background-color-picker' }, + getEventPositionElement(event), + (val) => { + const color: string | undefined = val?.color + if (color === undefined) return - if (color === 'transparent') { - editor.commands.unsetBackgroundColor() - } else { - editor.commands.setBackgroundColor(color) + if (color === 'transparent') { + editor.commands.unsetBackgroundColor() + } else { + editor.commands.setBackgroundColor(color) + } + resolve() + }, + undefined, + { + id: 'text-editor-background-color-picker', + category: 'popup', + overlay: true } - resolve() - }) + ) }) } export async function openTextColorOptions (editor: Editor, event: MouseEvent): Promise { await new Promise((resolve) => { - showPopup(ColorPicker, { palette: palette.text }, getEventPositionElement(event), (val) => { - const color: string | undefined = val?.color - if (color === undefined) return + showPopup( + ColorPicker, + { palette: palette.text, letters: true }, + getEventPositionElement(event), + (val) => { + const color: string | undefined = val?.color + if (color === undefined) return - if (color === 'var(--theme-text-color-primary)') { - editor.commands.unsetTextColor() - } else { - editor.commands.setTextColor(color) + if (color === 'var(--theme-text-primary-color)') { + editor.commands.unsetTextColor() + } else { + editor.commands.setTextColor(color) + } + resolve() + }, + undefined, + { + id: 'text-editor-text-color-picker', + category: 'popup', + overlay: true } - resolve() - }) + ) }) } diff --git a/plugins/text-editor-resources/src/components/extension/listkeymap.ts b/plugins/text-editor-resources/src/components/extension/listkeymap.ts index eb1c9e842e..f82f8291c9 100644 --- a/plugins/text-editor-resources/src/components/extension/listkeymap.ts +++ b/plugins/text-editor-resources/src/components/extension/listkeymap.ts @@ -13,8 +13,10 @@ // limitations under the License. // -import { type Editor } from '@tiptap/core' -import ListKeymap, { type ListKeymapOptions, listHelpers } from '@tiptap/extension-list-keymap' +import { isAtStartOfNode, isNodeActive, type Editor } from '@tiptap/core' +import ListKeymap, { listHelpers, type ListKeymapOptions } from '@tiptap/extension-list-keymap' +import { type ResolvedPos, type Node } from '@tiptap/pm/model' +import { type EditorState } from '@tiptap/pm/state' /** * Workaround for the original ListKeymap extension issue that @@ -33,7 +35,7 @@ export const ListKeymapExtension = ListKeymap.extend({ if (editor.state.schema.nodes[itemName] === undefined) { return } - if (listHelpers.handleBackspace(editor, itemName, wrapperNames)) { + if (handleListItemBackspace(editor, itemName, wrapperNames)) { handled = true } }) @@ -86,3 +88,144 @@ export const ListKeymapExtension = ListKeymap.extend({ } } }) + +export const handleListItemBackspace = (editor: Editor, name: string, parentListTypes: string[]): boolean => { + // this is required to still handle the undo handling + if (editor.commands.undoInputRule()) { + return true + } + + // if the selection is not collapsed + // we can rely on the default backspace behavior + if (editor.state.selection.from !== editor.state.selection.to) { + return false + } + + // if the current item is NOT inside a list item & + // the previous item is a list (orderedList or bulletList) + // move the cursor into the list and delete the current item + if (!isNodeActive(editor.state, name) && listHelpers.hasListBefore(editor.state, name, parentListTypes)) { + const { $anchor } = editor.state.selection + + const $listPos = editor.state.doc.resolve($anchor.before() - 1) + + const listDescendants: Array<{ node: Node, pos: number }> = [] + + $listPos.node().descendants((node, pos) => { + if (node.type.isInGroup('listItems')) { + listDescendants.push({ node, pos }) + } + }) + + const lastItem = listDescendants.at(-1) + + if (lastItem === undefined) { + return false + } + + const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1) + + return editor + .chain() + .cut({ from: $anchor.start() - 1, to: $anchor.end() + 1 }, $lastItemPos.end()) + .joinForward() + .run() + } + + // if the cursor is not inside the current node type + // do nothing and proceed + if (!isNodeActive(editor.state, name)) { + return false + } + + const $from = editor.state.selection.$from + const parentOffset = $from.depth > 0 ? $from.index($from.depth - 1) : 0 + + // if the cursor is not at the start of a node + // do nothing and proceed + if (!isAtStartOfNode(editor.state) || parentOffset > 0) { + return false + } + + const listItemPos = findListItemPos(editor.state) + if (listItemPos === null) { + return false + } + + const $prev = editor.state.doc.resolve(listItemPos.$pos.pos - 2) + const prevNode = $prev.node(listItemPos.depth) + + const previousListItemHasSubList = listItemHasSubList(prevNode) + + if (hasListItemBefore(editor.state)) { + // if the previous item is a list item and doesn't have a sublist, join the list items + if (!previousListItemHasSubList) { + return editor.commands.joinItemBackward() + } else { + return editor.chain().sinkListItem(name).joinItemBackward().run() + } + } + + // otherwise in the end, a backspace should + // always just lift the list item if + // joining / merging is not possible + return editor.chain().liftListItem(name).run() +} + +const findListItemPos = (state: EditorState): { $pos: ResolvedPos, depth: number } | null => { + const { $from } = state.selection + + let currentNode = null + let currentDepth = $from.depth + let currentPos = $from.pos + let targetDepth: number | null = null + + while (currentDepth > 0 && targetDepth === null) { + currentNode = $from.node(currentDepth) + + if (currentNode.type.isInGroup('listItems')) { + targetDepth = currentDepth + } else { + currentDepth -= 1 + currentPos -= 1 + } + } + + if (targetDepth === null) { + return null + } + + return { $pos: state.doc.resolve(currentPos), depth: targetDepth } +} + +const listItemHasSubList = (node?: Node): boolean => { + if (node === undefined) { + return false + } + + let hasSubList = false + + node.descendants((child) => { + if (child.type.isInGroup('listItems')) { + hasSubList = true + } + }) + + return hasSubList +} + +const hasListItemBefore = (state: EditorState): boolean => { + const { $anchor } = state.selection + + const $targetPos = state.doc.resolve($anchor.pos - 2) + + if ($targetPos.index() === 0) { + return false + } + + if (!($targetPos.nodeBefore?.type.isInGroup('listItems') ?? false)) { + return false + } + + return true +} diff --git a/plugins/text-editor-resources/src/components/extension/popups/ColorPicker.svelte b/plugins/text-editor-resources/src/components/extension/popups/ColorPicker.svelte index b199769bd2..d536c97adc 100644 --- a/plugins/text-editor-resources/src/components/extension/popups/ColorPicker.svelte +++ b/plugins/text-editor-resources/src/components/extension/popups/ColorPicker.svelte @@ -17,6 +17,7 @@ import { Card } from '@hcengineering/presentation' export let palette: Array<{ color: string, preview?: string }> = [{ color: 'transparent' }] + export let letters: boolean = false const dispatch = createEventDispatcher() @@ -32,11 +33,15 @@
{ handleSubmit(k) }} - /> + > + {#if letters}A{/if} +
{/each} @@ -61,6 +66,20 @@ height: 1.5rem; border-radius: 0.25rem; cursor: pointer; + } + + .solid { + background-color: var(--color); box-shadow: var(--text-editor-color-picker-outline) 0px 0px 0px 1px inset; } + + .letters { + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + border: 1px solid var(--color); + color: var(--color); + font-weight: bold; + } diff --git a/server-plugins/controlled-documents-resources/src/index.ts b/server-plugins/controlled-documents-resources/src/index.ts index a078023fb2..6d9db5e80b 100644 --- a/server-plugins/controlled-documents-resources/src/index.ts +++ b/server-plugins/controlled-documents-resources/src/index.ts @@ -9,7 +9,7 @@ import documents, { DocumentApprovalRequest, DocumentState, DocumentTemplate, - getEffectiveDocUpdate, + getEffectiveDocUpdates, type DocumentRequest, type DocumentTraining } from '@hcengineering/controlled-documents' @@ -54,8 +54,9 @@ async function getDocs ( return allDocs.filter(predicate) } -function makeDocEffective (doc: ControlledDocument, txFactory: TxFactory): Tx { - return txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, getEffectiveDocUpdate()) +function makeDocEffective (doc: ControlledDocument, txFactory: TxFactory): Tx[] { + const updates = getEffectiveDocUpdates() + return updates.map((u) => txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, u)) } function archiveDocs (docs: ControlledDocument[], txFactory: TxFactory): Tx[] { @@ -339,7 +340,7 @@ export async function OnDocPlannedEffectiveDateChanged ( if (tx.operations.plannedEffectiveDate === 0 && doc.controlledState === ControlledDocumentState.Approved) { // Create with not derived tx factory in order for notifications to work const factory = new TxFactory(control.txFactory.account) - await control.apply(control.ctx, [makeDocEffective(doc, factory)]) + await control.apply(control.ctx, makeDocEffective(doc, factory)) } } @@ -364,7 +365,7 @@ export async function OnDocApprovalRequestApproved ( // Create with not derived tx factory in order for notifications to work const factory = new TxFactory(control.txFactory.account) - await control.apply(control.ctx, [makeDocEffective(doc, factory)]) + await control.apply(control.ctx, makeDocEffective(doc, factory)) // make doc effective immediately } return result