mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-17 06:45:24 +00:00
refac
This commit is contained in:
parent
2771c26729
commit
aeb5288a3c
3 changed files with 372 additions and 99 deletions
65
src/app.css
65
src/app.css
|
|
@ -707,25 +707,29 @@ body {
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drop indicators: draw a line before/after the LI */
|
:root {
|
||||||
|
--pm-accent: color-mix(in oklab, Highlight 70%, transparent);
|
||||||
|
--pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
|
||||||
|
--pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.pm-li-drop-before,
|
.pm-li-drop-before,
|
||||||
.pm-li-drop-after,
|
.pm-li-drop-after,
|
||||||
.pm-li-drop-on-left,
|
.pm-li-drop-into,
|
||||||
.pm-li-drop-on-right {
|
.pm-li-drop-outdent {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* BEFORE/AFTER lines */
|
||||||
.pm-li-drop-before::before,
|
.pm-li-drop-before::before,
|
||||||
.pm-li-drop-after::after,
|
.pm-li-drop-after::after {
|
||||||
.pm-li-drop-on-left::before,
|
|
||||||
.pm-li-drop-on-right::after {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -24px; /* extend line into gutter past the handle */
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 3px;
|
||||||
background: currentColor;
|
background: var(--pm-accent);
|
||||||
opacity: 0.55;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.pm-li-drop-before::before {
|
.pm-li-drop-before::before {
|
||||||
top: -2px;
|
top: -2px;
|
||||||
|
|
@ -734,20 +738,35 @@ body {
|
||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* existing */
|
.pm-li-drop-before,
|
||||||
.pm-li-drop-before {
|
.pm-li-drop-after,
|
||||||
outline: 2px solid var(--accent);
|
.pm-li-drop-into,
|
||||||
outline-offset: -2px;
|
.pm-li-drop-outdent {
|
||||||
}
|
background: var(--pm-fill-target);
|
||||||
.pm-li-drop-after {
|
border-radius: 6px;
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* new */
|
.pm-li-drop-outdent::before {
|
||||||
.pm-li-drop-on-left {
|
content: '';
|
||||||
box-shadow: inset 4px 0 0 0 var(--accent);
|
position: absolute;
|
||||||
|
inset-block: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: color-mix(in oklab, Highlight 35%, transparent);
|
||||||
}
|
}
|
||||||
.pm-li-drop-on-right {
|
|
||||||
box-shadow: inset -4px 0 0 0 var(--accent);
|
.pm-li--with-handle:has(.pm-li-drop-before),
|
||||||
|
.pm-li--with-handle:has(.pm-li-drop-after),
|
||||||
|
.pm-li--with-handle:has(.pm-li-drop-into),
|
||||||
|
.pm-li--with-handle:has(.pm-li-drop-outdent) {
|
||||||
|
background: var(--pm-fill-ancestor);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-li-drop-before,
|
||||||
|
.pm-li-drop-after,
|
||||||
|
.pm-li-drop-into,
|
||||||
|
.pm-li-drop-outdent {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1099,11 +1099,11 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if richText && showFormattingToolbar}
|
{#if richText && showFormattingToolbar}
|
||||||
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0">
|
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0 {editor ? '' : 'hidden'}">
|
||||||
<FormattingButtons {editor} />
|
<FormattingButtons {editor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div bind:this={floatingMenuElement} id="floating-menu" class="p-0">
|
<div bind:this={floatingMenuElement} id="floating-menu" class="p-0 {editor ? '' : 'hidden'}">
|
||||||
<FormattingButtons {editor} />
|
<FormattingButtons {editor} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,173 @@
|
||||||
// listPointerDragPlugin.js
|
import { Plugin, PluginKey, NodeSelection } from 'prosemirror-state';
|
||||||
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
||||||
import { Decoration, DecorationSet } from 'prosemirror-view';
|
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||||
|
import { Fragment } from 'prosemirror-model';
|
||||||
|
|
||||||
export const listPointerDragKey = new PluginKey('listPointerDrag');
|
export const listPointerDragKey = new PluginKey('listPointerDrag');
|
||||||
|
|
||||||
export function listDragHandlePlugin(options = {}) {
|
export function listDragHandlePlugin(options = {}) {
|
||||||
const {
|
const {
|
||||||
itemTypeNames = ['list_item'], // add 'taskItem' if using tiptap task-list
|
itemTypeNames = ['listItem', 'taskItem', 'list_item'],
|
||||||
|
|
||||||
|
// Tiptap editor getter (required for indent/outdent)
|
||||||
|
getEditor = null,
|
||||||
|
|
||||||
|
// UI copy / classes
|
||||||
handleTitle = 'Drag to move',
|
handleTitle = 'Drag to move',
|
||||||
handleInnerHTML = '⋮⋮',
|
handleInnerHTML = '⋮⋮',
|
||||||
classItemWithHandle = 'pm-li--with-handle',
|
classItemWithHandle = 'pm-li--with-handle',
|
||||||
classHandle = 'pm-list-drag-handle',
|
classHandle = 'pm-list-drag-handle',
|
||||||
classDropBefore = 'pm-li-drop-before',
|
classDropBefore = 'pm-li-drop-before',
|
||||||
classDropAfter = 'pm-li-drop-after',
|
classDropAfter = 'pm-li-drop-after',
|
||||||
|
classDropInto = 'pm-li-drop-into',
|
||||||
|
classDropOutdent = 'pm-li-drop-outdent',
|
||||||
classDraggingGhost = 'pm-li-ghost',
|
classDraggingGhost = 'pm-li-ghost',
|
||||||
dragThresholdPx = 2 // ignore tiny wiggles
|
|
||||||
|
// Behavior
|
||||||
|
dragThresholdPx = 2,
|
||||||
|
intoThresholdX = 28, // X ≥ this → treat as “into” (indent)
|
||||||
|
outdentThresholdX = 10 // X ≤ this → “outdent”
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const itemTypesSet = new Set(itemTypeNames);
|
const itemTypesSet = new Set(itemTypeNames);
|
||||||
const isListItem = (node) => node && itemTypesSet.has(node.type.name);
|
const isListItem = (node) => node && itemTypesSet.has(node.type.name);
|
||||||
// ---------- decoration builder ----------
|
|
||||||
|
const listTypeNames = new Set([
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'taskList',
|
||||||
|
'bullet_list',
|
||||||
|
'ordered_list'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isListNode = (node) => node && listTypeNames.has(node.type.name);
|
||||||
|
|
||||||
|
function listTypeToItemTypeName(listNode) {
|
||||||
|
const name = listNode?.type?.name;
|
||||||
|
if (!name) return null;
|
||||||
|
|
||||||
|
// Prefer tiptap names first, then ProseMirror snake_case
|
||||||
|
if (name === 'taskList') {
|
||||||
|
return itemTypesSet.has('taskItem') ? 'taskItem' : null;
|
||||||
|
}
|
||||||
|
if (name === 'orderedList' || name === 'bulletList') {
|
||||||
|
return itemTypesSet.has('listItem')
|
||||||
|
? 'listItem'
|
||||||
|
: itemTypesSet.has('list_item')
|
||||||
|
? 'list_item'
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (name === 'ordered_list' || name === 'bullet_list') {
|
||||||
|
return itemTypesSet.has('list_item')
|
||||||
|
? 'list_item'
|
||||||
|
: itemTypesSet.has('listItem')
|
||||||
|
? 'listItem'
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the nearest enclosing list container at/around a pos
|
||||||
|
function getEnclosingListAt(doc, pos) {
|
||||||
|
const $pos = doc.resolve(Math.max(1, Math.min(pos, doc.content.size - 1)));
|
||||||
|
for (let d = $pos.depth; d >= 0; d--) {
|
||||||
|
const n = $pos.node(d);
|
||||||
|
if (isListNode(n)) {
|
||||||
|
const start = $pos.before(d);
|
||||||
|
return { node: n, depth: d, start, end: start + n.nodeSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemForList(state, itemNode, targetListNodeOrType) {
|
||||||
|
const schema = state.schema;
|
||||||
|
|
||||||
|
const targetListNode = targetListNodeOrType;
|
||||||
|
const wantedItemTypeName =
|
||||||
|
typeof targetListNode === 'string'
|
||||||
|
? targetListNode // allow passing type name directly
|
||||||
|
: listTypeToItemTypeName(targetListNode);
|
||||||
|
|
||||||
|
if (!wantedItemTypeName) return itemNode;
|
||||||
|
const wantedType = schema.nodes[wantedItemTypeName];
|
||||||
|
if (!wantedType) return itemNode;
|
||||||
|
|
||||||
|
const wantedListType = schema.nodes[targetListNode.type.name];
|
||||||
|
if (!wantedListType) return itemNode;
|
||||||
|
|
||||||
|
// Deep‑normalize children recursively
|
||||||
|
const normalizeNode = (node, parentTargetListNode) => {
|
||||||
|
console.log(
|
||||||
|
'Normalizing node',
|
||||||
|
node.type.name,
|
||||||
|
'for parent list',
|
||||||
|
parentTargetListNode?.type?.name
|
||||||
|
);
|
||||||
|
if (isListNode(node)) {
|
||||||
|
// Normalize each list item inside
|
||||||
|
const normalizedItems = [];
|
||||||
|
node.content.forEach((li) => {
|
||||||
|
normalizedItems.push(normalizeItemForList(state, li, parentTargetListNode));
|
||||||
|
});
|
||||||
|
return wantedListType.create(node.attrs, Fragment.from(normalizedItems), node.marks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a list node → but may contain lists deeper
|
||||||
|
if (node.content && node.content.size > 0) {
|
||||||
|
const nChildren = [];
|
||||||
|
node.content.forEach((ch) => {
|
||||||
|
nChildren.push(normalizeNode(ch, parentTargetListNode));
|
||||||
|
});
|
||||||
|
return node.type.create(node.attrs, Fragment.from(nChildren), node.marks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaf
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedContent = [];
|
||||||
|
itemNode.content.forEach((child) => {
|
||||||
|
normalizedContent.push(normalizeNode(child, targetListNode));
|
||||||
|
});
|
||||||
|
|
||||||
|
const newAttrs = {};
|
||||||
|
if (wantedType.attrs) {
|
||||||
|
for (const key in wantedType.attrs) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(itemNode.attrs || {}, key)) {
|
||||||
|
newAttrs[key] = itemNode.attrs[key];
|
||||||
|
} else {
|
||||||
|
const spec = wantedType.attrs[key];
|
||||||
|
newAttrs[key] = typeof spec?.default !== 'undefined' ? spec.default : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantedItemTypeName !== itemNode.type.name) {
|
||||||
|
// If changing type, ensure no disallowed marks are kept
|
||||||
|
const allowed = wantedType.spec?.marks;
|
||||||
|
const marks = allowed ? itemNode.marks.filter((m) => allowed.includes(m.type.name)) : [];
|
||||||
|
|
||||||
|
console.log(normalizedContent);
|
||||||
|
return wantedType.create(newAttrs, Fragment.from(normalizedContent), marks);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
|
||||||
|
} catch {
|
||||||
|
// Fallback – wrap content if schema requires a block
|
||||||
|
const para = schema.nodes.paragraph;
|
||||||
|
if (para) {
|
||||||
|
const wrapped =
|
||||||
|
itemNode.content.firstChild?.type === para
|
||||||
|
? Fragment.from(normalizedContent)
|
||||||
|
: Fragment.from([para.create(null, normalizedContent)]);
|
||||||
|
return wantedType.create(newAttrs, wrapped, itemNode.marks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
|
||||||
|
}
|
||||||
|
// ---------- decorations ----------
|
||||||
function buildHandleDecos(doc) {
|
function buildHandleDecos(doc) {
|
||||||
const decos = [];
|
const decos = [];
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
|
|
@ -33,15 +184,16 @@ export function listDragHandlePlugin(options = {}) {
|
||||||
el.setAttribute('aria-label', 'Drag list item');
|
el.setAttribute('aria-label', 'Drag list item');
|
||||||
el.contentEditable = 'false';
|
el.contentEditable = 'false';
|
||||||
el.innerHTML = handleInnerHTML;
|
el.innerHTML = handleInnerHTML;
|
||||||
el.pmGetPos = getPos; // live resolver
|
el.pmGetPos = getPos;
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
{ side: -1, ignoreSelection: true, key: `li-handle-${pos}` }
|
{ side: -1, ignoreSelection: true }
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return DecorationSet.create(doc, decos);
|
return DecorationSet.create(doc, decos);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findListItemAround($pos) {
|
function findListItemAround($pos) {
|
||||||
for (let d = $pos.depth; d > 0; d--) {
|
for (let d = $pos.depth; d > 0; d--) {
|
||||||
const node = $pos.node(d);
|
const node = $pos.node(d);
|
||||||
|
|
@ -52,33 +204,46 @@ export function listDragHandlePlugin(options = {}) {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function infoFromCoords(view, clientX, clientY) {
|
function infoFromCoords(view, clientX, clientY) {
|
||||||
const result = view.posAtCoords({ left: clientX, top: clientY });
|
const result = view.posAtCoords({ left: clientX, top: clientY });
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
const $pos = view.state.doc.resolve(result.pos);
|
const $pos = view.state.doc.resolve(result.pos);
|
||||||
const li = findListItemAround($pos);
|
const li = findListItemAround($pos);
|
||||||
if (!li) return null;
|
if (!li) return null;
|
||||||
|
|
||||||
const dom = /** @type {Element} */ (view.nodeDOM(li.start));
|
const dom = /** @type {Element} */ (view.nodeDOM(li.start));
|
||||||
if (!(dom instanceof Element)) return null;
|
if (!(dom instanceof Element)) return null;
|
||||||
|
|
||||||
const rect = dom.getBoundingClientRect();
|
const rect = dom.getBoundingClientRect();
|
||||||
const side = clientY - rect.top < rect.height / 2 ? 'before' : 'after';
|
const isRTL = getComputedStyle(dom).direction === 'rtl';
|
||||||
return { ...li, dom, side };
|
const xFromLeft = isRTL ? rect.right - clientX : clientX - rect.left;
|
||||||
|
const yInTopHalf = clientY - rect.top < rect.height / 2;
|
||||||
|
|
||||||
|
const mode =
|
||||||
|
xFromLeft <= outdentThresholdX
|
||||||
|
? 'outdent'
|
||||||
|
: xFromLeft >= intoThresholdX
|
||||||
|
? 'into'
|
||||||
|
: yInTopHalf
|
||||||
|
? 'before'
|
||||||
|
: 'after';
|
||||||
|
|
||||||
|
return { ...li, dom, mode };
|
||||||
}
|
}
|
||||||
// ---------- state shape ----------
|
|
||||||
|
// ---------- state ----------
|
||||||
const init = (state) => ({
|
const init = (state) => ({
|
||||||
decorations: buildHandleDecos(state.doc),
|
decorations: buildHandleDecos(state.doc),
|
||||||
dragging: null, // {fromStart, startMouse: {x,y}, ghostEl} | null
|
dragging: null, // {fromStart, startMouse:{x,y}, ghostEl, active}
|
||||||
dropTarget: null // {start, end, side} | null
|
dropTarget: null // {start, end, mode, toPos}
|
||||||
});
|
});
|
||||||
|
|
||||||
const apply = (tr, prev) => {
|
const apply = (tr, prev) => {
|
||||||
let next = prev;
|
let decorations = tr.docChanged
|
||||||
let decorations = prev.decorations;
|
? buildHandleDecos(tr.doc)
|
||||||
if (tr.docChanged) {
|
: prev.decorations.map(tr.mapping, tr.doc);
|
||||||
decorations = buildHandleDecos(tr.doc);
|
let next = { ...prev, decorations };
|
||||||
} else {
|
|
||||||
decorations = decorations.map(tr.mapping, tr.doc);
|
|
||||||
}
|
|
||||||
next = { ...next, decorations };
|
|
||||||
const meta = tr.getMeta(listPointerDragKey);
|
const meta = tr.getMeta(listPointerDragKey);
|
||||||
if (meta) {
|
if (meta) {
|
||||||
if (meta.type === 'set-drag') next = { ...next, dragging: meta.dragging };
|
if (meta.type === 'set-drag') next = { ...next, dragging: meta.dragging };
|
||||||
|
|
@ -87,75 +252,115 @@ export function listDragHandlePlugin(options = {}) {
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const decorationsProp = (state) => {
|
const decorationsProp = (state) => {
|
||||||
const ps = listPointerDragKey.getState(state);
|
const ps = listPointerDragKey.getState(state);
|
||||||
if (!ps) return null;
|
if (!ps) return null;
|
||||||
let deco = ps.decorations;
|
let deco = ps.decorations;
|
||||||
if (ps.dropTarget) {
|
if (ps.dropTarget) {
|
||||||
const { start, end, side } = ps.dropTarget;
|
const { start, end, mode } = ps.dropTarget;
|
||||||
const cls = side === 'before' ? classDropBefore : classDropAfter;
|
const cls =
|
||||||
|
mode === 'before'
|
||||||
|
? classDropBefore
|
||||||
|
: mode === 'after'
|
||||||
|
? classDropAfter
|
||||||
|
: mode === 'into'
|
||||||
|
? classDropInto
|
||||||
|
: classDropOutdent;
|
||||||
deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]);
|
deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]);
|
||||||
}
|
}
|
||||||
return deco;
|
return deco;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- helpers ----------
|
// ---------- helpers ----------
|
||||||
function setDrag(view, dragging) {
|
const setDrag = (view, dragging) =>
|
||||||
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging }));
|
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging }));
|
||||||
}
|
const setDrop = (view, drop) =>
|
||||||
function setDrop(view, drop) {
|
|
||||||
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop }));
|
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop }));
|
||||||
}
|
const clearAll = (view) =>
|
||||||
function clearAll(view) {
|
|
||||||
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' }));
|
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' }));
|
||||||
}
|
|
||||||
function moveItem(view, fromStart, toPos) {
|
function moveItem(view, fromStart, toPos) {
|
||||||
const { state, dispatch } = view;
|
const { state, dispatch } = view;
|
||||||
const { doc } = state;
|
const { doc } = state;
|
||||||
|
const orig = doc.nodeAt(fromStart);
|
||||||
|
if (!orig || !isListItem(orig)) return { ok: false };
|
||||||
|
|
||||||
const node = doc.nodeAt(fromStart);
|
// no-op if dropping into own range
|
||||||
if (!node || !isListItem(node)) return false;
|
if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize)
|
||||||
|
return { ok: true, newStart: fromStart };
|
||||||
|
|
||||||
// No-op if dropping inside itself
|
// find item depth
|
||||||
if (toPos >= fromStart && toPos <= fromStart + node.nodeSize) return true;
|
|
||||||
|
|
||||||
// Resolve a position inside the list_item to read its ancestry
|
|
||||||
const $inside = doc.resolve(fromStart + 1);
|
const $inside = doc.resolve(fromStart + 1);
|
||||||
|
|
||||||
// Find the list_item and its parent list
|
|
||||||
let itemDepth = -1;
|
let itemDepth = -1;
|
||||||
for (let d = $inside.depth; d > 0; d--) {
|
for (let d = $inside.depth; d > 0; d--) {
|
||||||
if ($inside.node(d) === node) {
|
if ($inside.node(d) === orig) {
|
||||||
itemDepth = d;
|
itemDepth = d;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (itemDepth < 0) return false;
|
if (itemDepth < 0) return { ok: false };
|
||||||
|
|
||||||
const listDepth = itemDepth - 1;
|
const listDepth = itemDepth - 1;
|
||||||
const parentList = $inside.node(listDepth);
|
const parentList = $inside.node(listDepth);
|
||||||
const parentListStart = $inside.before(listDepth);
|
const parentListStart = $inside.before(listDepth);
|
||||||
|
|
||||||
// If the parent list has only this one child, delete the whole list.
|
// delete item (or entire list if only child)
|
||||||
// Otherwise, just delete the single list_item.
|
|
||||||
const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart;
|
const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart;
|
||||||
const deleteTo =
|
const deleteTo =
|
||||||
parentList.childCount === 1
|
parentList.childCount === 1
|
||||||
? parentListStart + parentList.nodeSize
|
? parentListStart + parentList.nodeSize
|
||||||
: fromStart + node.nodeSize;
|
: fromStart + orig.nodeSize;
|
||||||
|
|
||||||
let tr = state.tr.delete(deleteFrom, deleteTo);
|
let tr = state.tr.delete(deleteFrom, deleteTo);
|
||||||
|
|
||||||
// Map the drop position through the deletion. Use a right bias so
|
// Compute mapped drop point with right bias so "after" stays after
|
||||||
// dropping "after" the deleted block stays after the gap.
|
|
||||||
const mappedTo = tr.mapping.map(toPos, 1);
|
const mappedTo = tr.mapping.map(toPos, 1);
|
||||||
|
|
||||||
tr = tr.insert(mappedTo, node);
|
// Detect enclosing list at destination, then normalize the item type
|
||||||
|
const listAtDest = getEnclosingListAt(tr.doc, mappedTo);
|
||||||
|
const nodeToInsert = listAtDest ? normalizeItemForList(state, orig, listAtDest.node) : orig;
|
||||||
|
|
||||||
|
try {
|
||||||
|
tr = tr.insert(mappedTo, nodeToInsert);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Direct insert failed, trying to wrap in list', e);
|
||||||
|
// If direct insert fails (e.g., not inside a list), try wrapping in a list
|
||||||
|
const schema = state.schema;
|
||||||
|
const wrapName =
|
||||||
|
parentList.type.name === 'taskList'
|
||||||
|
? schema.nodes.taskList
|
||||||
|
? 'taskList'
|
||||||
|
: null
|
||||||
|
: parentList.type.name === 'orderedList' || parentList.type.name === 'ordered_list'
|
||||||
|
? schema.nodes.orderedList
|
||||||
|
? 'orderedList'
|
||||||
|
: schema.nodes.ordered_list
|
||||||
|
? 'ordered_list'
|
||||||
|
: null
|
||||||
|
: schema.nodes.bulletList
|
||||||
|
? 'bulletList'
|
||||||
|
: schema.nodes.bullet_list
|
||||||
|
? 'bullet_list'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (wrapName) {
|
||||||
|
const wrapType = schema.nodes[wrapName];
|
||||||
|
if (wrapType) {
|
||||||
|
const frag = wrapType.create(null, normalizeItemForList(state, orig, wrapType));
|
||||||
|
tr = tr.insert(mappedTo, frag);
|
||||||
|
} else {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(tr.scrollIntoView());
|
dispatch(tr.scrollIntoView());
|
||||||
return true;
|
return { ok: true, newStart: mappedTo };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create & update a simple ghost box that follows the pointer
|
|
||||||
function ensureGhost(view, fromStart) {
|
function ensureGhost(view, fromStart) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = classDraggingGhost;
|
el.className = classDraggingGhost;
|
||||||
|
|
@ -168,16 +373,15 @@ export function listDragHandlePlugin(options = {}) {
|
||||||
el.style.width = rect.width + 'px';
|
el.style.width = rect.width + 'px';
|
||||||
el.style.pointerEvents = 'none';
|
el.style.pointerEvents = 'none';
|
||||||
el.style.opacity = '0.75';
|
el.style.opacity = '0.75';
|
||||||
// lightweight content
|
|
||||||
el.textContent = dom.textContent?.trim().slice(0, 80) || '…';
|
el.textContent = dom.textContent?.trim().slice(0, 80) || '…';
|
||||||
}
|
}
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
function updateGhost(ghost, x, y) {
|
const updateGhost = (ghost, dx, dy) => {
|
||||||
if (!ghost) return;
|
if (ghost) ghost.style.transform = `translate(${Math.round(dx)}px, ${Math.round(dy)}px)`;
|
||||||
ghost.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
};
|
||||||
}
|
|
||||||
// ---------- plugin ----------
|
// ---------- plugin ----------
|
||||||
return new Plugin({
|
return new Plugin({
|
||||||
key: listPointerDragKey,
|
key: listPointerDragKey,
|
||||||
|
|
@ -185,65 +389,115 @@ export function listDragHandlePlugin(options = {}) {
|
||||||
props: {
|
props: {
|
||||||
decorations: decorationsProp,
|
decorations: decorationsProp,
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
// Start dragging with a handle press (pointerdown => capture move/up on window)
|
|
||||||
mousedown(view, event) {
|
mousedown(view, event) {
|
||||||
const target = /** @type {HTMLElement} */ (event.target);
|
const t = /** @type {HTMLElement} */ (event.target);
|
||||||
const handle = target.closest?.(`.${classHandle}`);
|
const handle = t.closest?.(`.${classHandle}`);
|
||||||
if (!handle) return false;
|
if (!handle) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const getPos = handle.pmGetPos;
|
const getPos = handle.pmGetPos;
|
||||||
if (typeof getPos !== 'function') return true;
|
if (typeof getPos !== 'function') return true;
|
||||||
|
|
||||||
const posInside = getPos();
|
const posInside = getPos();
|
||||||
const fromStart = posInside - 1;
|
const fromStart = posInside - 1;
|
||||||
// visually select the node if allowed (optional)
|
|
||||||
try {
|
try {
|
||||||
const { NodeSelection } = require('prosemirror-state');
|
view.dispatch(
|
||||||
const sel = NodeSelection.create(view.state.doc, fromStart);
|
view.state.tr.setSelection(NodeSelection.create(view.state.doc, fromStart))
|
||||||
view.dispatch(view.state.tr.setSelection(sel));
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const startMouse = { x: event.clientX, y: event.clientY };
|
const startMouse = { x: event.clientX, y: event.clientY };
|
||||||
const ghostEl = ensureGhost(view, fromStart);
|
const ghostEl = ensureGhost(view, fromStart);
|
||||||
setDrag(view, { fromStart, startMouse, ghostEl, active: false });
|
setDrag(view, { fromStart, startMouse, ghostEl, active: false });
|
||||||
|
|
||||||
const onMove = (e) => {
|
const onMove = (e) => {
|
||||||
const ps = listPointerDragKey.getState(view.state);
|
const ps = listPointerDragKey.getState(view.state);
|
||||||
if (!ps?.dragging) return;
|
if (!ps?.dragging) return;
|
||||||
|
|
||||||
const dx = e.clientX - ps.dragging.startMouse.x;
|
const dx = e.clientX - ps.dragging.startMouse.x;
|
||||||
const dy = e.clientY - ps.dragging.startMouse.y;
|
const dy = e.clientY - ps.dragging.startMouse.y;
|
||||||
// Mark as active if moved beyond threshold
|
|
||||||
if (!ps.dragging.active && Math.hypot(dx, dy) > dragThresholdPx) {
|
if (!ps.dragging.active && Math.hypot(dx, dy) > dragThresholdPx) {
|
||||||
setDrag(view, { ...ps.dragging, active: true });
|
setDrag(view, { ...ps.dragging, active: true });
|
||||||
}
|
}
|
||||||
updateGhost(ps.dragging.ghostEl, dx, dy);
|
updateGhost(ps.dragging.ghostEl, dx, dy);
|
||||||
|
|
||||||
const info = infoFromCoords(view, e.clientX, e.clientY);
|
const info = infoFromCoords(view, e.clientX, e.clientY);
|
||||||
if (!info) {
|
if (!info) return setDrop(view, null);
|
||||||
setDrop(view, null);
|
|
||||||
return;
|
// for before/after: obvious
|
||||||
|
// for into/outdent: we still insert AFTER target and then run sink/lift
|
||||||
|
const toPos =
|
||||||
|
info.mode === 'before' ? info.start : info.mode === 'after' ? info.end : info.end; // into/outdent insert after target
|
||||||
|
|
||||||
|
const prev = listPointerDragKey.getState(view.state)?.dropTarget;
|
||||||
|
if (
|
||||||
|
!prev ||
|
||||||
|
prev.start !== info.start ||
|
||||||
|
prev.end !== info.end ||
|
||||||
|
prev.mode !== info.mode
|
||||||
|
) {
|
||||||
|
setDrop(view, { start: info.start, end: info.end, mode: info.mode, toPos });
|
||||||
}
|
}
|
||||||
const toPos = info.side === 'before' ? info.start : info.end;
|
|
||||||
const same =
|
|
||||||
ps.dropTarget &&
|
|
||||||
ps.dropTarget.start === info.start &&
|
|
||||||
ps.dropTarget.end === info.end &&
|
|
||||||
ps.dropTarget.side === info.side;
|
|
||||||
if (!same) setDrop(view, { start: info.start, end: info.end, side: info.side, toPos });
|
|
||||||
};
|
};
|
||||||
const endDrag = (e) => {
|
|
||||||
|
const endDrag = () => {
|
||||||
window.removeEventListener('mousemove', onMove, true);
|
window.removeEventListener('mousemove', onMove, true);
|
||||||
window.removeEventListener('mouseup', endDrag, true);
|
window.removeEventListener('mouseup', endDrag, true);
|
||||||
|
|
||||||
const ps = listPointerDragKey.getState(view.state);
|
const ps = listPointerDragKey.getState(view.state);
|
||||||
if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
|
if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
|
||||||
|
|
||||||
|
// Helper: figure out the list item node type name at/around a pos
|
||||||
|
const getListItemTypeNameAt = (doc, pos) => {
|
||||||
|
const direct = doc.nodeAt(pos);
|
||||||
|
if (direct && isListItem(direct)) return direct.type.name;
|
||||||
|
|
||||||
|
const $pos = doc.resolve(Math.min(pos + 1, doc.content.size));
|
||||||
|
for (let d = $pos.depth; d > 0; d--) {
|
||||||
|
const n = $pos.node(d);
|
||||||
|
if (isListItem(n)) return n.type.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = ['taskItem', 'listItem', 'list_item'];
|
||||||
|
for (const p of prefs) if (itemTypesSet.has(p)) return p;
|
||||||
|
return Array.from(itemTypesSet)[0];
|
||||||
|
};
|
||||||
|
|
||||||
if (ps?.dragging && ps?.dropTarget && ps.dragging.active) {
|
if (ps?.dragging && ps?.dropTarget && ps.dragging.active) {
|
||||||
const toPos =
|
const { fromStart } = ps.dragging;
|
||||||
ps.dropTarget.side === 'before' ? ps.dropTarget.start : ps.dropTarget.end;
|
const { toPos, mode } = ps.dropTarget;
|
||||||
moveItem(view, ps.dragging.fromStart, toPos);
|
|
||||||
|
const res = moveItem(view, fromStart, toPos);
|
||||||
|
|
||||||
|
if (res.ok && typeof res.newStart === 'number' && getEditor) {
|
||||||
|
const editor = getEditor();
|
||||||
|
if (editor?.commands) {
|
||||||
|
// Select the moved node so sink/lift applies to it
|
||||||
|
editor.commands.setNodeSelection(res.newStart);
|
||||||
|
|
||||||
|
const typeName = getListItemTypeNameAt(view.state.doc, res.newStart);
|
||||||
|
const chain = editor.chain().focus();
|
||||||
|
|
||||||
|
if (mode === 'into') {
|
||||||
|
if (editor.can().sinkListItem?.(typeName)) chain.sinkListItem(typeName).run();
|
||||||
|
else chain.run();
|
||||||
|
} else {
|
||||||
|
chain.run(); // finalize focus/selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAll(view);
|
clearAll(view);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('mousemove', onMove, true);
|
window.addEventListener('mousemove', onMove, true);
|
||||||
window.addEventListener('mouseup', endDrag, true);
|
window.addEventListener('mouseup', endDrag, true);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
// Escape cancels
|
|
||||||
keydown(view, event) {
|
keydown(view, event) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
const ps = listPointerDragKey.getState(view.state);
|
const ps = listPointerDragKey.getState(view.state);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue