Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa439d7c7b | |||
| affa32cbaa |
@@ -53,7 +53,13 @@ export function AppHeader() {
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
// Must match the AppShell navbar breakpoint (md). The navbar
|
||||
// collapses to the MOBILE drawer below md, so the mobile toggle
|
||||
// (which flips mobileOpened) must be the one visible across the
|
||||
// whole <md band — otherwise at 768-991 the desktop toggle showed
|
||||
// but flipped the wrong atom, leaving the drawer unopenable (the
|
||||
// regression from the initial sm->md navbar change).
|
||||
hiddenFrom="md"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -63,7 +69,7 @@ export function AppHeader() {
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
visibleFrom="md"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -88,7 +88,13 @@ export default function GlobalAppShell({
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
width: isSpaceRoute ? sidebarWidth : 300,
|
||||
breakpoint: "sm",
|
||||
// `md` (not `sm`): below 992px the fixed ~300px sidebar leaves too little
|
||||
// room for content — the settings tables (Members/…) overflow the offset
|
||||
// content area on tablet (~768px) and clip the Role/actions columns
|
||||
// off-screen with no horizontal scroll. Collapsing the navbar to a toggle
|
||||
// drawer across the whole tablet band frees the full width for content
|
||||
// (the mobile drawer is closed by default, so nothing overlaps on load).
|
||||
breakpoint: "md",
|
||||
collapsed: {
|
||||
mobile: !mobileOpened,
|
||||
desktop: !desktopOpened,
|
||||
@@ -97,7 +103,7 @@ export default function GlobalAppShell({
|
||||
aside={
|
||||
isPageRoute && {
|
||||
width: 420,
|
||||
breakpoint: "sm",
|
||||
breakpoint: "md",
|
||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { InputRule } from "@tiptap/core";
|
||||
import {
|
||||
Plugin,
|
||||
PluginKey,
|
||||
type EditorState,
|
||||
type Transaction,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
|
||||
// Region restored by the latest undo — while it is intact, typography
|
||||
// input rules overlapping it must not fire again.
|
||||
interface UndoGuardRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
const undoGuardKey = new PluginKey<UndoGuardRange | null>(
|
||||
"typographyUndoGuard",
|
||||
);
|
||||
|
||||
// prosemirror-history does not export its plugin key, so template-editor
|
||||
// undo/redo is detected via the stable stringified key. Only one
|
||||
// PluginKey("history") exists in the dependency tree, so "history$" is stable.
|
||||
const HISTORY_META = "history$";
|
||||
|
||||
const isUndoRedoTransaction = (tr: Transaction): boolean => {
|
||||
if (tr.getMeta(HISTORY_META)) {
|
||||
return true;
|
||||
}
|
||||
// Read yjs undo/redo meta via the real ySyncPluginKey object (imported, not
|
||||
// a fragile stringified key), which y-tiptap sets on Y.UndoManager changes.
|
||||
const ySyncMeta = tr.getMeta(ySyncPluginKey) as
|
||||
| { isUndoRedoOperation?: boolean }
|
||||
| undefined;
|
||||
return !!ySyncMeta?.isUndoRedoOperation;
|
||||
};
|
||||
|
||||
interface DocChange {
|
||||
from: number;
|
||||
oldTo: number;
|
||||
newTo: number;
|
||||
}
|
||||
|
||||
// Compute the minimal changed region between two docs. yjs undo/redo (and any
|
||||
// remote change) arrives as a whole-document replace step, so the transaction
|
||||
// step maps are useless — diff the docs to recover the real minimal change.
|
||||
// Returns null when the docs are identical.
|
||||
const findChangedRange = (
|
||||
oldState: EditorState,
|
||||
newState: EditorState,
|
||||
): DocChange | null => {
|
||||
const start = oldState.doc.content.findDiffStart(newState.doc.content);
|
||||
const end = oldState.doc.content.findDiffEnd(newState.doc.content);
|
||||
if (start == null || end == null) {
|
||||
return null;
|
||||
}
|
||||
let { a: oldTo, b: newTo } = end;
|
||||
// Normalize overlapping diff bounds (repeated-content edge case).
|
||||
if (oldTo < start) {
|
||||
newTo += start - oldTo;
|
||||
oldTo = start;
|
||||
}
|
||||
return { from: start, oldTo, newTo };
|
||||
};
|
||||
|
||||
// Map an armed guard range across a single document change described by a diff.
|
||||
// Returns null when the change touches the guarded text itself (the restored
|
||||
// substitution was edited, so the guard must be released).
|
||||
const mapRangeThroughChange = (
|
||||
range: UndoGuardRange,
|
||||
change: DocChange,
|
||||
): UndoGuardRange | null => {
|
||||
// Strict intersection: an edit exactly at a guard boundary (e.g. the user
|
||||
// typing the suppressed space right after the restored text, or deleting it)
|
||||
// must NOT drop the guard.
|
||||
if (change.from < range.to && change.oldTo > range.from) {
|
||||
return null;
|
||||
}
|
||||
// Change fully before the guard: shift the guard by the length delta.
|
||||
if (change.oldTo <= range.from) {
|
||||
const delta = change.newTo - change.oldTo;
|
||||
return { from: range.from + delta, to: range.to + delta };
|
||||
}
|
||||
// Change fully after the guard: positions are unaffected.
|
||||
return range;
|
||||
};
|
||||
|
||||
// Detect history/remote transactions that may arrive as a whole-document
|
||||
// replace step: prosemirror-history undo/redo, or any yjs remote-origin change
|
||||
// (isChangeOrigin is the canonical predicate already used across the app).
|
||||
const isHistoryOrRemoteTransaction = (tr: Transaction): boolean =>
|
||||
!!tr.getMeta(HISTORY_META) || isChangeOrigin(tr);
|
||||
|
||||
export const CustomTypography = Typography.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() ?? []),
|
||||
new Plugin({
|
||||
key: undoGuardKey,
|
||||
state: {
|
||||
init: () => null,
|
||||
apply(tr, prev, oldState, newState): UndoGuardRange | null {
|
||||
if (tr.docChanged && isHistoryOrRemoteTransaction(tr)) {
|
||||
const change = findChangedRange(oldState, newState);
|
||||
if (change == null) {
|
||||
// Attribute-only or otherwise content-neutral change: keep the
|
||||
// guard.
|
||||
return prev;
|
||||
}
|
||||
// Arm the guard only when the LOCAL user's undo/redo REPLACED text
|
||||
// (deleted + inserted) — the signature of reverting an input-rule
|
||||
// substitution. Pure insertions/deletions and remote peer edits
|
||||
// must not arm it.
|
||||
if (
|
||||
isUndoRedoTransaction(tr) &&
|
||||
change.oldTo > change.from &&
|
||||
change.newTo > change.from
|
||||
) {
|
||||
return { from: change.from, to: change.newTo };
|
||||
}
|
||||
// Non-arming history/remote change: map the existing guard through
|
||||
// the real diff instead of the (whole-document) step map.
|
||||
if (!prev) {
|
||||
return null;
|
||||
}
|
||||
return mapRangeThroughChange(prev, change);
|
||||
}
|
||||
if (!prev) {
|
||||
return null;
|
||||
}
|
||||
if (!tr.docChanged) {
|
||||
return prev;
|
||||
}
|
||||
// Ordinary local edit: minimal step maps are accurate and cheap.
|
||||
let range: UndoGuardRange | null = prev;
|
||||
for (const stepMap of tr.mapping.maps) {
|
||||
const { from: rangeFrom, to: rangeTo } = range;
|
||||
let touched = false;
|
||||
stepMap.forEach((fromA, toA) => {
|
||||
if (fromA < rangeTo && toA > rangeFrom) {
|
||||
touched = true;
|
||||
}
|
||||
});
|
||||
if (touched) {
|
||||
range = null;
|
||||
break;
|
||||
}
|
||||
range = {
|
||||
from: stepMap.map(rangeFrom, 1),
|
||||
to: stepMap.map(rangeTo, -1),
|
||||
};
|
||||
}
|
||||
return range && range.to > range.from ? range : null;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
// Wrap every typography rule: skip it when its match overlaps the text
|
||||
// just restored by undo, so an undone substitution is not re-applied.
|
||||
return (this.parent?.() ?? []).map(
|
||||
(rule) =>
|
||||
new InputRule({
|
||||
find: rule.find,
|
||||
undoable: rule.undoable,
|
||||
handler: (props) => {
|
||||
const guard = undoGuardKey.getState(props.state);
|
||||
if (
|
||||
guard &&
|
||||
props.range.from < guard.to &&
|
||||
props.range.to > guard.from
|
||||
) {
|
||||
// Returning null skips this rule and lets the typed character
|
||||
// be inserted as plain text.
|
||||
return null;
|
||||
}
|
||||
return rule.handler(props);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import SubScript from "@tiptap/extension-subscript";
|
||||
import { CustomTypography } from "./custom-typography";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
@@ -245,9 +245,7 @@ export const mainExtensions = [
|
||||
return ReactMarkViewRenderer(SpoilerView);
|
||||
},
|
||||
}),
|
||||
// Typography with an undo guard: does not re-apply a substitution the user
|
||||
// just undid (e.g. Ctrl+Z on "1/2" -> "½" followed by another space).
|
||||
CustomTypography,
|
||||
Typography,
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
|
||||
|
||||
Reference in New Issue
Block a user