Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions components/dashboard/preferences/LanguageSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"use client";

import { useCallback, useMemo } from "react";
import { Check, Download, Loader2 } from "lucide-react";
import form from "./../../utils/Form.module.css";
import sharedStyles from "../project/ProjectSettings.module.css";
import styles from "./SpellcheckSettings.module.css";
import { UserLanguage } from "@src/lib/utils/types";
import { useLocale } from "@src/context/LocaleContext";
import { useSpellcheck } from "@src/context/SpellcheckContext";
import { DICTIONARY_CATALOG, formatDictionarySize } from "@src/lib/spellcheck/spellcheck-dictionaries";
import Dropdown, { DropdownOption } from "@components/utils/Dropdown";
import { useTranslations } from "next-intl";

Expand All @@ -21,6 +26,67 @@ const LANGUAGE_OPTIONS: DropdownOption[] = [
const LanguageSettings = () => {
const { locale, setLanguage } = useLocale();
const t = useTranslations("language");
const {
spellcheckLang,
setSpellcheckLang,
installedDictionaries,
downloadProgress,
installDictionary,
} = useSpellcheck();

const spellcheckOptions: DropdownOption[] = useMemo(() => {
const noneOption: DropdownOption = {
value: "none",
label: t("spellcheckNone"),
};

const dictOptions: DropdownOption[] = DICTIONARY_CATALOG.map((dict) => {
const installed = installedDictionaries.find((d) => d.code === dict.code);
const isDownloading = downloadProgress?.code === dict.code;

return {
value: dict.code,
label: (
<div className={styles.dictOption}>
<span>{dict.name}</span>
<span className={styles.dictMeta}>
{isDownloading ? (
<Loader2 size={14} className={styles.spinner} />
) : installed ? (
<>
<span className={styles.size}>{formatDictionarySize(installed.size)}</span>
<Check size={14} className={styles.checkmark} />
</>
) : (
<Download size={14} className={styles.download} />
)}
</span>
</div>
),
triggerLabel: dict.name,
};
});

return [noneOption, ...dictOptions];
}, [installedDictionaries, downloadProgress, t]);

const handleSpellcheckChange = useCallback(
(value: string) => {
if (value === "none") {
setSpellcheckLang(null);
return;
}

const isInstalled = installedDictionaries.some((d) => d.code === value);
if (isInstalled) {
setSpellcheckLang(value);
} else {
// Download and then auto-activate
installDictionary(value);
}
},
[installedDictionaries, setSpellcheckLang, installDictionary],
);

return (
<div className={sharedStyles.settingsForm}>
Expand All @@ -34,6 +100,17 @@ const LanguageSettings = () => {
/>
<p className={sharedStyles.helpText}>{t("helpText")}</p>
</div>

<div className={sharedStyles.formGroup}>
<label className={form.label}>{t("spellcheckLabel")}</label>
<Dropdown
value={spellcheckLang ?? "none"}
onChange={handleSpellcheckChange}
options={spellcheckOptions}
className={sharedStyles.input}
/>
<p className={sharedStyles.helpText}>{t("spellcheckHelpText")}</p>
</div>
</div>
);
};
Expand Down
40 changes: 40 additions & 0 deletions components/dashboard/preferences/SpellcheckSettings.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.dictOption {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 8px;
}

.dictMeta {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}

.checkmark {
color: var(--success);
}

.download {
color: var(--secondary-text);
opacity: 0.7;
}

.size {
font-size: 0.75rem;
color: var(--secondary-text);
min-width: 48px;
text-align: right;
}

.spinner {
animation: spin 1s linear infinite;
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}
21 changes: 19 additions & 2 deletions components/editor/DocumentEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,23 @@ const DocumentEditorPanel = ({
const onEditorContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!editor) return;

// Check if right-clicking on a spellcheck error
const target = e.target as HTMLElement;
const spellErrorEl = target.closest(".spellcheck-error") as HTMLElement | null;
if (spellErrorEl) {
e.preventDefault();
const word = spellErrorEl.textContent || "";
const from = editor.view.posAtDOM(spellErrorEl, 0);
const to = from + word.length;
updateContextMenu({
type: ContextMenuType.Spellcheck,
position: { x: e.clientX, y: e.clientY },
typeSpecificProps: { word, from, to },
});
return;
}

const { from, to } = editor.state.selection;
if (from === to) return;

Expand Down Expand Up @@ -431,9 +448,9 @@ const DocumentEditorPanel = ({
onMouseDown={handleContainerMouseDown}
onFocus={() => setFocusedEditorType(focusType)}
>
<div className={`${styles.editor_wrapper} ${isEndlessScroll && config.type === "screenplay" ? styles.endless_scroll : ""}`}>
<div className={`${styles.editor_wrapper} ${isEndlessScroll ? styles.endless_scroll : ""}`}>
<div className={join(styles.editor_shadow, isScrolled ? styles.show_shadow : "")} />
<div onContextMenu={config.features.comments ? onEditorContextMenu : undefined}>
<div onContextMenu={config.features.comments || config.features.spellcheck ? onEditorContextMenu : undefined}>
<EditorContent editor={editor} spellCheck={false} />
</div>
</div>
Expand Down
45 changes: 45 additions & 0 deletions components/editor/sidebar/ContextMenu.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,48 @@
.menu_item:hover {
background-color: var(--editor-sidebar-hover);
}

.suggestion_item {
display: flex;
gap: 10px;

padding-block: 8px;
padding-left: 12px;
padding-right: 35px;

font-size: 14px;
font-weight: 600;
border-bottom: 1px solid;
border-color: var(--separator);
cursor: pointer;
}

.suggestion_item:hover {
background-color: var(--editor-sidebar-hover);
}

.menu_label {
display: flex;
gap: 10px;
align-items: center;

padding-block: 8px;
padding-left: 12px;
padding-right: 35px;

font-size: 13px;
color: var(--secondary-text);
font-style: italic;
border-bottom: 1px solid;
border-color: var(--separator);
}

.spinner {
animation: spin 1s linear infinite;
}

@keyframes spin {
to {
transform: rotate(360deg);
}
}
90 changes: 89 additions & 1 deletion components/editor/sidebar/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"use client";

import { useContext, useEffect } from "react";
import { useContext, useEffect, useState } from "react";
import { UserContext } from "@src/context/UserContext";
import { useSpellcheck } from "@src/context/SpellcheckContext";
import { refreshSpellcheck } from "@src/lib/spellcheck/spellcheck-extension";
import { Scene } from "@src/lib/screenplay/scenes";

import context from "./ContextMenu.module.css";
Expand All @@ -13,8 +15,10 @@ import { ProjectContext } from "@src/context/ProjectContext";
import { useTranslations } from "next-intl";
import {
ArrowDownRight,
BookPlus,
ClipboardPaste,
Highlighter,
Loader2,
LucideIcon,
MessageSquarePlus,
Pencil,
Expand Down Expand Up @@ -43,6 +47,7 @@ export const enum ContextMenuType {
LocationItem,
Suggestion,
EditorSelection,
Spellcheck,
}

type ContextMenuItemProps = {
Expand Down Expand Up @@ -202,6 +207,87 @@ const EditorSelectionMenu = (props: any) => {
);
};

/* ============================== */
/* Spellcheck context menu */
/* ============================== */

export type SpellcheckContextProps = {
word: string;
from: number;
to: number;
};

const SpellcheckMenu = (props: any) => {
const t = useTranslations("contextMenu");
const { editor, repository } = useContext(ProjectContext);
const { worker } = useSpellcheck();
const { updateContextMenu } = useContext(UserContext);
const { word, from, to } = props.props as SpellcheckContextProps;
const [suggestions, setSuggestions] = useState<string[] | null>(null);

useEffect(() => {
if (!worker) {
setSuggestions([]);
return;
}

const handler = (e: MessageEvent) => {
if (e.data.type === "SUGGEST_RESULT" && e.data.word === word) {
worker.removeEventListener("message", handler);
setSuggestions(e.data.suggestions);
}
};

worker.addEventListener("message", handler);
worker.postMessage({ type: "SUGGEST", word });

return () => worker.removeEventListener("message", handler);
}, [worker, word]);

const handleReplace = (suggestion: string) => {
if (!editor) return;
const tr = editor.state.tr.replaceWith(from, to, editor.state.schema.text(suggestion));
editor.view.dispatch(tr);
updateContextMenu(undefined);
};

const handleAddToDictionary = () => {
if (!editor) return;
// Save to project-level Yjs dictionary (synced to collaborators).
// The observer in use-document-editor will pick this up and send ADD_WORD to the worker.
const projectState = repository?.getState();
if (projectState) {
projectState.dictionary().set(word, true);
} else if (worker) {
// Fallback: send directly to worker if no project state
worker.postMessage({ type: "ADD_WORD", word });
refreshSpellcheck(editor);
}
updateContextMenu(undefined);
};

return (
<>
{suggestions === null && (
<div className={context.menu_label}>
<Loader2 size={14} className={context.spinner} />
</div>
)}
{suggestions !== null && suggestions.length === 0 && (
<div className={context.menu_label}>
<span>{t("noSuggestions")}</span>
</div>
)}
{suggestions?.map((s) => (
<div key={s} className={context.suggestion_item} onClick={() => handleReplace(s)}>
<p className="unselectable">{s}</p>
</div>
))}
<ContextMenuItem text={t("addToDictionary")} icon={BookPlus} action={handleAddToDictionary} />
</>
);
};

const renderContextMenu = (contextMenu: ContextMenuProps) => {
switch (contextMenu.type) {
case ContextMenuType.SceneList:
Expand All @@ -216,6 +302,8 @@ const renderContextMenu = (contextMenu: ContextMenuProps) => {
return <LocationItemMenu props={contextMenu.typeSpecificProps} />;
case ContextMenuType.EditorSelection:
return <EditorSelectionMenu props={contextMenu.typeSpecificProps} />;
case ContextMenuType.Spellcheck:
return <SpellcheckMenu props={contextMenu.typeSpecificProps} />;
}
};

Expand Down
Loading