configuration wiki editor link to bind with slug
This commit is contained in:
@@ -90,6 +90,22 @@ async function requestJsonInternal<T>(
|
|||||||
envelope.status === "error";
|
envelope.status === "error";
|
||||||
if (isError) {
|
if (isError) {
|
||||||
const message = extractErrorMessage(payload, envelope) || "Request failed";
|
const message = extractErrorMessage(payload, envelope) || "Request failed";
|
||||||
|
|
||||||
|
// Some backends return 200 with {status:false,message:"Invalid or expired JWT"} instead of HTTP 401.
|
||||||
|
// In that case, try refresh + retry once to keep UX smooth.
|
||||||
|
if (
|
||||||
|
!options?.skipRefresh &&
|
||||||
|
!options?.skipAuth &&
|
||||||
|
typeof input === "string" &&
|
||||||
|
!String(input).includes("/auth/") &&
|
||||||
|
isAuthTokenExpiredMessage(message)
|
||||||
|
) {
|
||||||
|
const refreshed = await tryRefreshTokens();
|
||||||
|
if (refreshed) {
|
||||||
|
return requestJsonInternal<T>(input, init, { ...(options || {}), skipRefresh: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw new ApiError(message, res.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
|
throw new ApiError(message, res.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
|
||||||
}
|
}
|
||||||
return (envelope.data ?? null) as T;
|
return (envelope.data ?? null) as T;
|
||||||
@@ -131,6 +147,18 @@ function extractErrorMessage(payload: unknown, envelope: ApiEnvelope<unknown> |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAuthTokenExpiredMessage(message: string): boolean {
|
||||||
|
const normalized = message.trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return (
|
||||||
|
normalized.includes("invalid or expired jwt") ||
|
||||||
|
normalized.includes("jwt expired") ||
|
||||||
|
normalized.includes("token expired") ||
|
||||||
|
normalized.includes("invalid token") ||
|
||||||
|
normalized.includes("expired token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function stringifyPayload(payload: unknown): string {
|
function stringifyPayload(payload: unknown): string {
|
||||||
if (typeof payload === "string") return payload;
|
if (typeof payload === "string") return payload;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+14
-1
@@ -1,9 +1,10 @@
|
|||||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
import { requestJson } from "@/uhm/api/http";
|
import { ApiError, requestJson } from "@/uhm/api/http";
|
||||||
|
|
||||||
export type Wiki = {
|
export type Wiki = {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
slug?: string | null;
|
||||||
content?: string;
|
content?: string;
|
||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
@@ -28,6 +29,18 @@ export async function fetchWikiById(id: string): Promise<Wiki> {
|
|||||||
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
return requestJson<Wiki>(`${API_ENDPOINTS.wikis}/${encodeURIComponent(wikiId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchWikiBySlug(slug: string): Promise<Wiki | null> {
|
||||||
|
const value = String(slug || "").trim();
|
||||||
|
if (!value.length) return null;
|
||||||
|
try {
|
||||||
|
return await requestJson<Wiki>(`${API_ENDPOINTS.wikis}/slug/${encodeURIComponent(value)}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Treat "not found" as an empty result for search UX.
|
||||||
|
if (err instanceof ApiError && err.status === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
||||||
const value = String(slug || "").trim();
|
const value = String(slug || "").trim();
|
||||||
if (!value.length) return false;
|
if (!value.length) return false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type ComponentProps } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import "react-quill-new/dist/quill.snow.css";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import Label from "@/components/form/Label";
|
|||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import { newId } from "@/uhm/lib/id";
|
import { newId } from "@/uhm/lib/id";
|
||||||
import type ReactQuill from "react-quill-new";
|
import type ReactQuill from "react-quill-new";
|
||||||
import { checkWikiSlugExists } from "@/uhm/api/wikis";
|
import { checkWikiSlugExists, fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||||
|
|
||||||
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
type ReactQuillProps = ComponentProps<typeof ReactQuill>;
|
||||||
|
|
||||||
@@ -50,6 +50,23 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false);
|
const [isCheckingCreateSlug, setIsCheckingCreateSlug] = useState(false);
|
||||||
|
|
||||||
|
// Quill: custom link UI (link-to-wiki by slug).
|
||||||
|
const wikiLinkIntentRef = useRef<{
|
||||||
|
quill: any;
|
||||||
|
range: { index: number; length: number } | null;
|
||||||
|
activeWikiId: string | null;
|
||||||
|
existingHref: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const wikiLinkHandlerRef = useRef<(quill: any) => void>(() => {});
|
||||||
|
const [isWikiLinkOpen, setIsWikiLinkOpen] = useState(false);
|
||||||
|
const [wikiLinkQuery, setWikiLinkQuery] = useState("");
|
||||||
|
const [wikiLinkError, setWikiLinkError] = useState<string | null>(null);
|
||||||
|
const [wikiLinkSearchMode, setWikiLinkSearchMode] = useState<"title" | "slug">("title");
|
||||||
|
const [globalWikiResults, setGlobalWikiResults] = useState<Wiki[]>([]);
|
||||||
|
const [isGlobalWikiSearching, setIsGlobalWikiSearching] = useState(false);
|
||||||
|
const [globalWikiSearchError, setGlobalWikiSearchError] = useState<string | null>(null);
|
||||||
|
const globalWikiSearchRequestRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoOpen) return;
|
if (!autoOpen) return;
|
||||||
// open once on mount
|
// open once on mount
|
||||||
@@ -193,6 +210,203 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeWikiLinkModal = useCallback(() => {
|
||||||
|
setIsWikiLinkOpen(false);
|
||||||
|
setWikiLinkQuery("");
|
||||||
|
setWikiLinkError(null);
|
||||||
|
setGlobalWikiResults([]);
|
||||||
|
setIsGlobalWikiSearching(false);
|
||||||
|
setGlobalWikiSearchError(null);
|
||||||
|
wikiLinkIntentRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
type WikiLinkOption = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
source: "local" | "global";
|
||||||
|
};
|
||||||
|
|
||||||
|
const localWikiLinkCandidates = useMemo<WikiLinkOption[]>(() => {
|
||||||
|
if (!isWikiLinkOpen) return [];
|
||||||
|
const q = wikiLinkQuery.trim().toLowerCase();
|
||||||
|
const active = wikiLinkIntentRef.current?.activeWikiId ?? activeId;
|
||||||
|
|
||||||
|
const base = (wikis || [])
|
||||||
|
.filter((w) => w && typeof w.id === "string")
|
||||||
|
.filter((w) => w.id !== active)
|
||||||
|
// Link value must be slug.
|
||||||
|
.filter((w) => typeof w.slug === "string" && w.slug.trim().length > 0);
|
||||||
|
|
||||||
|
const filtered = (() => {
|
||||||
|
if (!q.length) return base;
|
||||||
|
if (wikiLinkSearchMode === "slug") {
|
||||||
|
return base.filter((w) => String(w.slug || "").toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
return base.filter((w) => (w.title || "").toLowerCase().includes(q));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return filtered.slice(0, 20).map((w) => ({
|
||||||
|
key: `local:${w.id}`,
|
||||||
|
title: (w.title || "").trim() || "Untitled wiki",
|
||||||
|
slug: String(w.slug).trim(),
|
||||||
|
source: "local",
|
||||||
|
}));
|
||||||
|
}, [activeId, isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode, wikis]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWikiLinkOpen) return;
|
||||||
|
|
||||||
|
const keyword = wikiLinkQuery.trim();
|
||||||
|
if (!keyword.length) {
|
||||||
|
setGlobalWikiResults([]);
|
||||||
|
setIsGlobalWikiSearching(false);
|
||||||
|
setGlobalWikiSearchError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
const requestId = ++globalWikiSearchRequestRef.current;
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
setIsGlobalWikiSearching(true);
|
||||||
|
setGlobalWikiSearchError(null);
|
||||||
|
try {
|
||||||
|
const rows =
|
||||||
|
wikiLinkSearchMode === "slug"
|
||||||
|
? (() => fetchWikiBySlug(keyword))()
|
||||||
|
: (() => searchWikisByTitle(keyword, { limit: 12 }))();
|
||||||
|
|
||||||
|
const resolved = await rows;
|
||||||
|
if (disposed || requestId !== globalWikiSearchRequestRef.current) return;
|
||||||
|
|
||||||
|
const list = Array.isArray(resolved) ? resolved : resolved ? [resolved] : [];
|
||||||
|
setGlobalWikiResults(list);
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed || requestId !== globalWikiSearchRequestRef.current) return;
|
||||||
|
console.error("Search global wikis failed", err);
|
||||||
|
setGlobalWikiResults([]);
|
||||||
|
setGlobalWikiSearchError("Khong search duoc wiki tren server.");
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === globalWikiSearchRequestRef.current) {
|
||||||
|
setIsGlobalWikiSearching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 260);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [isWikiLinkOpen, wikiLinkQuery, wikiLinkSearchMode]);
|
||||||
|
|
||||||
|
const globalWikiLinkCandidates = useMemo<WikiLinkOption[]>(() => {
|
||||||
|
if (!isWikiLinkOpen) return [];
|
||||||
|
const active = wikiLinkIntentRef.current?.activeWikiId ?? activeId;
|
||||||
|
const activeSlug = (wikis || []).find((w) => w.id === active)?.slug ?? null;
|
||||||
|
const normalizedActiveSlug = typeof activeSlug === "string" ? activeSlug.trim() : "";
|
||||||
|
|
||||||
|
const out: WikiLinkOption[] = [];
|
||||||
|
for (const row of globalWikiResults || []) {
|
||||||
|
const slug = typeof row?.slug === "string" ? row.slug.trim() : "";
|
||||||
|
if (!slug.length) continue;
|
||||||
|
if (normalizedActiveSlug && slug === normalizedActiveSlug) continue;
|
||||||
|
out.push({
|
||||||
|
key: `global:${row.id || slug}`,
|
||||||
|
title: (row.title || "").trim() || "Untitled wiki",
|
||||||
|
slug,
|
||||||
|
source: "global",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.slice(0, 20);
|
||||||
|
}, [activeId, globalWikiResults, isWikiLinkOpen, wikis]);
|
||||||
|
|
||||||
|
const wikiLinkCandidates = useMemo<WikiLinkOption[]>(() => {
|
||||||
|
const localSlugs = new Set(localWikiLinkCandidates.map((w) => w.slug));
|
||||||
|
const dedupedGlobal = globalWikiLinkCandidates.filter((w) => !localSlugs.has(w.slug));
|
||||||
|
return [...localWikiLinkCandidates, ...dedupedGlobal];
|
||||||
|
}, [globalWikiLinkCandidates, localWikiLinkCandidates]);
|
||||||
|
|
||||||
|
const applyWikiLink = useCallback((target: WikiLinkOption) => {
|
||||||
|
const intent = wikiLinkIntentRef.current;
|
||||||
|
const quill = intent?.quill;
|
||||||
|
if (!quill) return;
|
||||||
|
|
||||||
|
const slug = target.slug.trim();
|
||||||
|
|
||||||
|
const range = intent?.range ?? quill.getSelection?.() ?? null;
|
||||||
|
if (!range) {
|
||||||
|
setWikiLinkError("Khong lay duoc vi tri selection trong editor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore selection to ensure format applies to the expected range.
|
||||||
|
quill.setSelection?.(range.index, range.length, "silent");
|
||||||
|
|
||||||
|
if (range.length > 0) {
|
||||||
|
quill.formatText?.(range.index, range.length, "link", slug, "user");
|
||||||
|
closeWikiLinkModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No selection: insert the wiki title (or slug) and link it.
|
||||||
|
const label = (target.title || "").trim() || slug;
|
||||||
|
quill.insertText?.(range.index, label, { link: slug }, "user");
|
||||||
|
quill.setSelection?.(range.index + label.length, 0, "silent");
|
||||||
|
closeWikiLinkModal();
|
||||||
|
}, [closeWikiLinkModal]);
|
||||||
|
|
||||||
|
const removeWikiLink = useCallback(() => {
|
||||||
|
const intent = wikiLinkIntentRef.current;
|
||||||
|
const quill = intent?.quill;
|
||||||
|
if (!quill) return;
|
||||||
|
const range = intent?.range ?? quill.getSelection?.() ?? null;
|
||||||
|
if (!range) return;
|
||||||
|
quill.setSelection?.(range.index, range.length, "silent");
|
||||||
|
if (range.length > 0) {
|
||||||
|
quill.formatText?.(range.index, range.length, "link", false, "user");
|
||||||
|
} else {
|
||||||
|
quill.format?.("link", false, "user");
|
||||||
|
}
|
||||||
|
closeWikiLinkModal();
|
||||||
|
}, [closeWikiLinkModal]);
|
||||||
|
|
||||||
|
// Keep handler ref updated while keeping modules object stable.
|
||||||
|
wikiLinkHandlerRef.current = (quill: any) => {
|
||||||
|
if (!quill) return;
|
||||||
|
const range = quill.getSelection?.() ?? null;
|
||||||
|
// Try to read current link format (if any) from the selection.
|
||||||
|
const existingHref =
|
||||||
|
range && (quill.getFormat?.(range)?.link ?? quill.getFormat?.(range.index, range.length)?.link) || null;
|
||||||
|
|
||||||
|
wikiLinkIntentRef.current = {
|
||||||
|
quill,
|
||||||
|
range,
|
||||||
|
activeWikiId: activeId,
|
||||||
|
existingHref: typeof existingHref === "string" ? existingHref : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed query with selected text (best effort).
|
||||||
|
const selectedText =
|
||||||
|
range && range.length > 0 ? String(quill.getText?.(range.index, range.length) || "").trim() : "";
|
||||||
|
setWikiLinkQuery(selectedText.slice(0, 80));
|
||||||
|
setWikiLinkError(null);
|
||||||
|
setIsWikiLinkOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const quillModules = useMemo(() => {
|
||||||
|
return {
|
||||||
|
toolbar: {
|
||||||
|
container: QUILL_TOOLBAR,
|
||||||
|
handlers: {
|
||||||
|
// NOTE: use function() to preserve Quill toolbar `this` binding.
|
||||||
|
link: function (this: any) {
|
||||||
|
wikiLinkHandlerRef.current(this?.quill);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -496,7 +710,7 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
theme="snow"
|
theme="snow"
|
||||||
value={wikiDocHtml}
|
value={wikiDocHtml}
|
||||||
onChange={(content: string) => setWikiDocHtml(content)}
|
onChange={(content: string) => setWikiDocHtml(content)}
|
||||||
modules={QUILL_MODULES}
|
modules={quillModules}
|
||||||
className="min-h-[320px]"
|
className="min-h-[320px]"
|
||||||
placeholder="Nhap noi dung wiki..."
|
placeholder="Nhap noi dung wiki..."
|
||||||
readOnly={!activeId}
|
readOnly={!activeId}
|
||||||
@@ -511,6 +725,99 @@ export default function WikiSidebarPanel({ projectId, wikis, setWikis, autoOpen,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isWikiLinkOpen}
|
||||||
|
onClose={closeWikiLinkModal}
|
||||||
|
className="max-w-[620px] p-6"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">Link wiki</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Search</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={wikiLinkQuery}
|
||||||
|
onChange={(e) => setWikiLinkQuery(e.target.value)}
|
||||||
|
className="h-11 flex-1 min-w-0 rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||||
|
placeholder={wikiLinkSearchMode === "slug" ? "Nhap slug..." : "Nhap title wiki..."}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={wikiLinkSearchMode}
|
||||||
|
onChange={(e) => setWikiLinkSearchMode(e.target.value === "slug" ? "slug" : "title")}
|
||||||
|
className="h-11 rounded-xl border border-gray-200 bg-transparent px-3 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
|
||||||
|
aria-label="Search mode"
|
||||||
|
>
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="slug">Slug</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{wikiLinkError ? (
|
||||||
|
<div className="mt-2 text-xs text-red-600 dark:text-red-300">{wikiLinkError}</div>
|
||||||
|
) : null}
|
||||||
|
{globalWikiSearchError ? (
|
||||||
|
<div className="mt-2 text-xs text-red-600 dark:text-red-300">{globalWikiSearchError}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[320px] overflow-auto rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-[#0d1117]">
|
||||||
|
<div className="p-2 grid gap-1">
|
||||||
|
{isGlobalWikiSearching ? (
|
||||||
|
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">Searching…</div>
|
||||||
|
) : null}
|
||||||
|
{wikiLinkCandidates.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyWikiLink(w)}
|
||||||
|
className="w-full text-left rounded-lg border border-transparent hover:border-gray-200 dark:hover:border-gray-800 hover:bg-gray-50 dark:hover:bg-white/[0.03] px-3 py-2 transition"
|
||||||
|
title={w.slug || undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{(w.title || "").trim() || "Untitled wiki"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{String(w.slug)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full border ${
|
||||||
|
w.source === "local"
|
||||||
|
? "border-emerald-300/60 text-emerald-600 dark:text-emerald-300"
|
||||||
|
: "border-blue-300/60 text-blue-600 dark:text-blue-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{w.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{wikiLinkCandidates.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Khong tim thay wiki phu hop (hoac cac wiki khac chua co slug).
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{wikiLinkIntentRef.current?.existingHref ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={removeWikiLink}>
|
||||||
|
Remove link
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="sm" variant="outline" onClick={closeWikiLinkModal}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -539,16 +846,14 @@ function CloseIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUILL_MODULES = {
|
const QUILL_TOOLBAR = [
|
||||||
toolbar: [
|
|
||||||
[{ header: [1, 2, 3, false] }],
|
[{ header: [1, 2, 3, false] }],
|
||||||
["bold", "italic", "underline", "strike"],
|
["bold", "italic", "underline", "strike"],
|
||||||
[{ list: "ordered" }, { list: "bullet" }],
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
["blockquote", "code-block"],
|
["blockquote", "code-block"],
|
||||||
["link", "image"],
|
["link", "image"],
|
||||||
["clean"],
|
["clean"],
|
||||||
],
|
];
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeWikiDocForQuill(doc: string | null): string {
|
function normalizeWikiDocForQuill(doc: string | null): string {
|
||||||
const raw = (doc || "").trim();
|
const raw = (doc || "").trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user