chore(editor): update focus and save workflows
- preserve grid layout while the editor is in focus mode\n- extract save orchestration into a dedicated lifecycle hook\n- simplify code formatting controls in the toolbar
This commit is contained in:
parent
42ad4105c1
commit
9203a22ed1
6 changed files with 177 additions and 99 deletions
|
|
@ -36,6 +36,8 @@ MemoEditor/
|
||||||
│ ├── EditorToolbar.tsx # Toolbar
|
│ ├── EditorToolbar.tsx # Toolbar
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── hooks/ # React hooks (utilities)
|
├── hooks/ # React hooks (utilities)
|
||||||
|
│ ├── useMemoSave.ts # Save transaction, cache invalidation, and reset
|
||||||
|
│ └── useFocusMode.ts # Scroll lock and layout-stable focus presentation
|
||||||
├── Editor/ # The CodeMirror 6 decorated-source editor
|
├── Editor/ # The CodeMirror 6 decorated-source editor
|
||||||
│ ├── index.tsx # React wrapper: mounts the EditorView, owns the
|
│ ├── index.tsx # React wrapper: mounts the EditorView, owns the
|
||||||
│ │ # controller refs, syncs initialContent in/out
|
│ │ # controller refs, syncs initialContent in/out
|
||||||
|
|
@ -90,6 +92,13 @@ Uses `useReducer` + Context for predictable state transitions. All state changes
|
||||||
|
|
||||||
Pure TypeScript functions containing business logic. No React hooks, easy to test.
|
Pure TypeScript functions containing business logic. No React hooks, easy to test.
|
||||||
|
|
||||||
|
### Lifecycle hooks
|
||||||
|
|
||||||
|
Cross-cutting React workflows stay outside the editor shell. `useMemoSave`
|
||||||
|
coordinates validation, persistence, query invalidation, and post-save reducer
|
||||||
|
state. `useFocusMode` owns focus mode's DOM lifecycle, including restoring the
|
||||||
|
previous body scroll style and preserving the editor's place in grid layouts.
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
|
|
||||||
Thin presentation components that dispatch actions and render UI.
|
Thin presentation components that dispatch actions and render UI.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
ListOrderedIcon,
|
ListOrderedIcon,
|
||||||
ListTodoIcon,
|
ListTodoIcon,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
SquareCodeIcon,
|
|
||||||
StrikethroughIcon,
|
StrikethroughIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Translations } from "@/utils/i18n";
|
import type { Translations } from "@/utils/i18n";
|
||||||
|
|
@ -110,12 +109,6 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
|
||||||
icon: StrikethroughIcon,
|
icon: StrikethroughIcon,
|
||||||
group: "mark",
|
group: "mark",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "code",
|
|
||||||
labelKey: "editor.format.code",
|
|
||||||
icon: CodeIcon,
|
|
||||||
group: "mark",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "bulletList",
|
id: "bulletList",
|
||||||
labelKey: "editor.format.bullet-list",
|
labelKey: "editor.format.bullet-list",
|
||||||
|
|
@ -137,7 +130,7 @@ export const EDITOR_COMMANDS: EditorCommand[] = [
|
||||||
{
|
{
|
||||||
id: "codeBlock",
|
id: "codeBlock",
|
||||||
labelKey: "editor.format.code-block",
|
labelKey: "editor.format.code-block",
|
||||||
icon: SquareCodeIcon,
|
icon: CodeIcon,
|
||||||
group: "block",
|
group: "block",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ export { useFocusMode } from "./useFocusMode";
|
||||||
export { useLinkMemo } from "./useLinkMemo";
|
export { useLinkMemo } from "./useLinkMemo";
|
||||||
export { useLocation } from "./useLocation";
|
export { useLocation } from "./useLocation";
|
||||||
export { useMemoInit } from "./useMemoInit";
|
export { useMemoInit } from "./useMemoInit";
|
||||||
|
export { useMemoSave } from "./useMemoSave";
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,40 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the DOM-only parts of focus mode: body scroll locking and a measured
|
||||||
|
* placeholder height that keeps masonry/grid layouts stable while the editor
|
||||||
|
* itself is fixed above the page.
|
||||||
|
*/
|
||||||
|
export function useFocusMode(isFocusMode: boolean) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const normalModeHeightRef = useRef(0);
|
||||||
|
|
||||||
export function useFocusMode(isFocusMode: boolean): void {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = isFocusMode ? "hidden" : "";
|
if (!isFocusMode) return;
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = "";
|
document.body.style.overflow = previousOverflow;
|
||||||
};
|
};
|
||||||
}, [isFocusMode]);
|
}, [isFocusMode]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || isFocusMode) return;
|
||||||
|
|
||||||
|
const updateHeight = () => {
|
||||||
|
normalModeHeightRef.current = container.getBoundingClientRect().height;
|
||||||
|
};
|
||||||
|
updateHeight();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateHeight);
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [isFocusMode]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
placeholderHeight: normalModeHeightRef.current,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
web/src/components/MemoEditor/hooks/useMemoSave.ts
Normal file
113
web/src/components/MemoEditor/hooks/useMemoSave.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useNewMemo } from "@/contexts/NewMemoContext";
|
||||||
|
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||||
|
import { userKeys } from "@/hooks/useUserQueries";
|
||||||
|
import { handleError } from "@/lib/error";
|
||||||
|
import type { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||||
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
import { errorService, memoService, validationService } from "../services";
|
||||||
|
import { useEditorContext } from "../state";
|
||||||
|
|
||||||
|
interface UseMemoSaveOptions {
|
||||||
|
memoName?: string;
|
||||||
|
parentMemoName?: string;
|
||||||
|
defaultVisibility?: Visibility;
|
||||||
|
defaultCreateTime?: Date;
|
||||||
|
discardDraft: () => void;
|
||||||
|
onConfirm?: (memoName: string) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the editor's save transaction and its post-save cache/state updates.
|
||||||
|
* Keeping this workflow outside the shell makes saving identical whether it is
|
||||||
|
* triggered by the toolbar or the editor keyboard shortcut.
|
||||||
|
*/
|
||||||
|
export function useMemoSave({
|
||||||
|
memoName,
|
||||||
|
parentMemoName,
|
||||||
|
defaultVisibility,
|
||||||
|
defaultCreateTime,
|
||||||
|
discardDraft,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: UseMemoSaveOptions): () => Promise<void> {
|
||||||
|
const t = useTranslate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { markNewMemo } = useNewMemo();
|
||||||
|
const { actions, dispatch, getState } = useEditorContext();
|
||||||
|
|
||||||
|
return useCallback(async () => {
|
||||||
|
const state = getState();
|
||||||
|
const { valid, reason } = validationService.canSave(state);
|
||||||
|
if (!valid) {
|
||||||
|
toast.error(reason || "Cannot save");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(actions.setLoading("saving", true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await memoService.save(state, { memoName, parentMemoName });
|
||||||
|
|
||||||
|
if (!result.hasChanges) {
|
||||||
|
toast.error(t("editor.no-changes-detected"));
|
||||||
|
onCancel?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent the autosave unmount flush from restoring the saved draft.
|
||||||
|
discardDraft();
|
||||||
|
|
||||||
|
const invalidationPromises = [
|
||||||
|
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: userKeys.stats() }),
|
||||||
|
];
|
||||||
|
if (memoName) {
|
||||||
|
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.detail(memoName) }));
|
||||||
|
}
|
||||||
|
if (parentMemoName) {
|
||||||
|
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.comments(parentMemoName) }));
|
||||||
|
}
|
||||||
|
await Promise.all(invalidationPromises);
|
||||||
|
|
||||||
|
dispatch(actions.reset());
|
||||||
|
if (!memoName && defaultVisibility) {
|
||||||
|
dispatch(actions.setMetadata({ visibility: defaultVisibility }));
|
||||||
|
}
|
||||||
|
// Reset creates a fresh editor state, so restore calendar-derived values
|
||||||
|
// for the next memo created without remounting this composer.
|
||||||
|
if (!memoName && defaultCreateTime) {
|
||||||
|
dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!memoName && !parentMemoName) {
|
||||||
|
markNewMemo(result.memoName);
|
||||||
|
}
|
||||||
|
onConfirm?.(result.memoName);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, toast.error, {
|
||||||
|
context: "Failed to save memo",
|
||||||
|
fallbackMessage: errorService.getErrorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
dispatch(actions.setLoading("saving", false));
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
actions,
|
||||||
|
defaultCreateTime,
|
||||||
|
defaultVisibility,
|
||||||
|
discardDraft,
|
||||||
|
dispatch,
|
||||||
|
getState,
|
||||||
|
markNewMemo,
|
||||||
|
memoName,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
parentMemoName,
|
||||||
|
queryClient,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useInstance } from "@/contexts/InstanceContext";
|
import { useInstance } from "@/contexts/InstanceContext";
|
||||||
import { useNewMemo } from "@/contexts/NewMemoContext";
|
|
||||||
import { useLocalStorage } from "@/hooks";
|
import { useLocalStorage } from "@/hooks";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
|
||||||
import { userKeys } from "@/hooks/useUserQueries";
|
|
||||||
import { handleError } from "@/lib/error";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
|
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { convertVisibilityFromString } from "@/utils/memo";
|
import { convertVisibilityFromString } from "@/utils/memo";
|
||||||
import { AudioRecorderPanel, EditorContent, EditorMetadata, FocusModeOverlay, TimestampPopover } from "./components";
|
import { AudioRecorderPanel, EditorContent, EditorMetadata, FocusModeOverlay, TimestampPopover } from "./components";
|
||||||
import { FOCUS_MODE_STYLES, FORMATTING_TOOLBAR_STORAGE_KEY } from "./constants";
|
import { FOCUS_MODE_STYLES, FORMATTING_TOOLBAR_STORAGE_KEY } from "./constants";
|
||||||
import { useAudioRecorder, useAutoSave, useFocusMode, useMemoInit } from "./hooks";
|
import { useAudioRecorder, useAutoSave, useFocusMode, useMemoInit, useMemoSave } from "./hooks";
|
||||||
import { errorService, memoService, transcriptionService, validationService } from "./services";
|
import { errorService, transcriptionService } from "./services";
|
||||||
import { EditorProvider, useEditorContext, useEditorSelector } from "./state";
|
import { EditorProvider, useEditorContext, useEditorSelector } from "./state";
|
||||||
import { EditorToolbar, FormattingToolbar } from "./Toolbar";
|
import { EditorToolbar, FormattingToolbar } from "./Toolbar";
|
||||||
import type { MemoEditorProps } from "./types";
|
import type { MemoEditorProps } from "./types";
|
||||||
|
|
@ -41,10 +36,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
||||||
onCancel,
|
onCancel,
|
||||||
}) => {
|
}) => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const editorRef = useRef<EditorController>(null);
|
const editorRef = useRef<EditorController>(null);
|
||||||
const { actions, dispatch, getState } = useEditorContext();
|
const { actions, dispatch } = useEditorContext();
|
||||||
// Subscribe only to the low-frequency slices this component renders from, so
|
// Subscribe only to the low-frequency slices this component renders from, so
|
||||||
// typing (which changes content) does not re-render the editor shell and its
|
// typing (which changes content) does not re-render the editor shell and its
|
||||||
// toolbar/metadata children.
|
// toolbar/metadata children.
|
||||||
|
|
@ -52,7 +46,6 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
||||||
const hasTimestamp = useEditorSelector((s) => Boolean(s.timestamps.createTime));
|
const hasTimestamp = useEditorSelector((s) => Boolean(s.timestamps.createTime));
|
||||||
const { userGeneralSetting } = useAuth();
|
const { userGeneralSetting } = useAuth();
|
||||||
const { aiSetting, fetchSetting } = useInstance();
|
const { aiSetting, fetchSetting } = useInstance();
|
||||||
const { markNewMemo } = useNewMemo();
|
|
||||||
const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false);
|
const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false);
|
||||||
const [isTranscribingAudio, setIsTranscribingAudio] = useState(false);
|
const [isTranscribingAudio, setIsTranscribingAudio] = useState(false);
|
||||||
// Persisted preference: also show the formatting toolbar in normal mode. Focus
|
// Persisted preference: also show the formatting toolbar in normal mode. Focus
|
||||||
|
|
@ -84,8 +77,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
||||||
// Auto-save content to localStorage (subscribes to the store internally).
|
// Auto-save content to localStorage (subscribes to the store internally).
|
||||||
const { discardDraft } = useAutoSave(currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled);
|
const { discardDraft } = useAutoSave(currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled);
|
||||||
|
|
||||||
// Focus mode management with body scroll lock
|
const { containerRef: editorContainerRef, placeholderHeight } = useFocusMode(isFocusMode);
|
||||||
useFocusMode(isFocusMode);
|
|
||||||
|
|
||||||
// Live-sync the draft's createTime/updateTime to the calendar-derived prop.
|
// Live-sync the draft's createTime/updateTime to the calendar-derived prop.
|
||||||
// Only applies in create mode; edit mode owns its own timestamps. Runs after
|
// Only applies in create mode; edit mode owns its own timestamps. Runs after
|
||||||
|
|
@ -227,80 +219,15 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleSave() {
|
const handleSave = useMemoSave({
|
||||||
// Read the latest state imperatively — this component no longer subscribes
|
memoName,
|
||||||
// to content, so the closure can't rely on a per-render `state` snapshot.
|
parentMemoName,
|
||||||
const state = getState();
|
defaultVisibility,
|
||||||
// Validate before saving
|
defaultCreateTime,
|
||||||
const { valid, reason } = validationService.canSave(state);
|
discardDraft,
|
||||||
if (!valid) {
|
onConfirm,
|
||||||
toast.error(reason || "Cannot save");
|
onCancel,
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(actions.setLoading("saving", true));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await memoService.save(state, { memoName, parentMemoName });
|
|
||||||
|
|
||||||
if (!result.hasChanges) {
|
|
||||||
toast.error(t("editor.no-changes-detected"));
|
|
||||||
onCancel?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear localStorage cache on successful save and prevent the unmount
|
|
||||||
// flush from writing the just-saved content back as a stale draft.
|
|
||||||
discardDraft();
|
|
||||||
|
|
||||||
// Invalidate React Query cache to refresh memo lists across the app
|
|
||||||
const invalidationPromises = [
|
|
||||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: userKeys.stats() }),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Ensure memo detail pages don't keep stale cached content after edits.
|
|
||||||
if (memoName) {
|
|
||||||
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.detail(memoName) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this was a comment, also invalidate the comments query for the parent memo
|
|
||||||
if (parentMemoName) {
|
|
||||||
invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.comments(parentMemoName) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(invalidationPromises);
|
|
||||||
|
|
||||||
// Reset editor state to initial values
|
|
||||||
dispatch(actions.reset());
|
|
||||||
if (!memoName && defaultVisibility) {
|
|
||||||
dispatch(actions.setMetadata({ visibility: defaultVisibility }));
|
|
||||||
}
|
|
||||||
// Re-seed the calendar-derived timestamps so the popover stays visible
|
|
||||||
// and subsequent memos in the same filter session keep the prefilled date.
|
|
||||||
// Without this, the live-sync effect won't re-fire (its deps don't change
|
|
||||||
// across reset), and memo #2 onward would silently fall back to "now".
|
|
||||||
if (!memoName && defaultCreateTime) {
|
|
||||||
dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Surface a freshly created top-level memo at the top of the list so it
|
|
||||||
// stays visible even when pinned memos would otherwise push it down.
|
|
||||||
if (!memoName && !parentMemoName) {
|
|
||||||
markNewMemo(result.memoName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify parent component of successful save
|
|
||||||
onConfirm?.(result.memoName);
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, toast.error, {
|
|
||||||
context: "Failed to save memo",
|
|
||||||
fallbackMessage: errorService.getErrorMessage(error),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
dispatch(actions.setLoading("saving", false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -312,12 +239,17 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
||||||
- In focus mode: becomes fixed with specific spacing, editor grows to fill space
|
- In focus mode: becomes fixed with specific spacing, editor grows to fill space
|
||||||
- In normal mode: stays relative with max-height constraint
|
- In normal mode: stays relative with max-height constraint
|
||||||
*/}
|
*/}
|
||||||
|
{isFocusMode && placeholderHeight > 0 && (
|
||||||
|
<div aria-hidden className={cn("w-full", className)} style={{ height: placeholderHeight }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={editorContainerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative w-full flex flex-col justify-between items-start bg-card px-4 pt-3 pb-1 rounded-lg border border-border gap-2",
|
"group relative w-full flex flex-col justify-between items-start bg-card px-4 pt-3 pb-1 rounded-lg border border-border gap-2",
|
||||||
FOCUS_MODE_STYLES.transition,
|
FOCUS_MODE_STYLES.transition,
|
||||||
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
|
isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
|
||||||
className,
|
!isFocusMode && className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Formatting toolbar. Always shown in focus mode (with an exit button);
|
{/* Formatting toolbar. Always shown in focus mode (with an exit button);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue