|
@@ -1,4 +1,4 @@
|
|
|
-import React, { useState, useEffect, useMemo } from "react";
|
|
|
|
|
|
|
+import React, { useState, useEffect, useRef, useMemo } from "react";
|
|
|
import { useNavigate, useParams, Link } from "react-router-dom";
|
|
import { useNavigate, useParams, Link } from "react-router-dom";
|
|
|
import MDEditor, { commands } from "@uiw/react-md-editor";
|
|
import MDEditor, { commands } from "@uiw/react-md-editor";
|
|
|
import "@uiw/react-md-editor/markdown-editor.css";
|
|
import "@uiw/react-md-editor/markdown-editor.css";
|
|
@@ -304,12 +304,77 @@ function PostEditor() {
|
|
|
ADD_ATTR: ["type", "min", "max", "value", "step", "style", "width", "height", "class", "data-line"], // Added data-line
|
|
ADD_ATTR: ["type", "min", "max", "value", "step", "style", "width", "height", "class", "data-line"], // Added data-line
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- const previewHtml = renderContent();
|
|
|
|
|
- const sanitizedPreview = getSanitizedHtml(previewHtml);
|
|
|
|
|
|
|
+ // Memoize markdown rendering to prevent unnecessary re-running
|
|
|
|
|
+ const sanitizedPreview = useMemo(() => {
|
|
|
|
|
+ return getSanitizedHtml(renderContent());
|
|
|
|
|
+ }, [formData.content]);
|
|
|
|
|
|
|
|
|
|
|
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
+ const previewRef = useRef(null);
|
|
|
|
|
+ const isScrollingRef = useRef(false);
|
|
|
|
|
+
|
|
|
|
|
+ // Scroll Sync: Editor -> Preview
|
|
|
|
|
+ const handleEditorScroll = (e) => {
|
|
|
|
|
+ if (!previewRef.current || viewMode !== "split") return;
|
|
|
|
|
+ if (isScrollingRef.current) return;
|
|
|
|
|
+
|
|
|
|
|
+ isScrollingRef.current = true;
|
|
|
|
|
+ const textarea = e.target;
|
|
|
|
|
+ const { scrollTop, scrollHeight, clientHeight } = textarea;
|
|
|
|
|
+
|
|
|
|
|
+ const scrollPercent = scrollTop / (scrollHeight - clientHeight);
|
|
|
|
|
+ const previewNode = previewRef.current;
|
|
|
|
|
+
|
|
|
|
|
+ if (previewNode) {
|
|
|
|
|
+ previewNode.scrollTop = scrollPercent * (previewNode.scrollHeight - previewNode.clientHeight);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setTimeout(() => { isScrollingRef.current = false; }, 50);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Scroll Sync: Preview -> Editor
|
|
|
|
|
+ const handlePreviewScroll = (e) => {
|
|
|
|
|
+ if (viewMode !== "split") return;
|
|
|
|
|
+ if (isScrollingRef.current) return;
|
|
|
|
|
+
|
|
|
|
|
+ isScrollingRef.current = true;
|
|
|
|
|
+ const previewNode = e.target;
|
|
|
|
|
+ const { scrollTop, scrollHeight, clientHeight } = previewNode;
|
|
|
|
|
+
|
|
|
|
|
+ const scrollPercent = scrollTop / (scrollHeight - clientHeight);
|
|
|
|
|
+
|
|
|
|
|
+ if (editorWrapperRef.current) {
|
|
|
|
|
+ const textarea = editorWrapperRef.current.querySelector('textarea');
|
|
|
|
|
+ if (textarea) {
|
|
|
|
|
+ textarea.scrollTop = scrollPercent * (textarea.scrollHeight - textarea.clientHeight);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setTimeout(() => { isScrollingRef.current = false; }, 50);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Handle Scroll Sync using capture on wrapper since MDEditor might consume scroll on textarea
|
|
|
|
|
+ // or better, ensure we target the right element.
|
|
|
|
|
+ // The previous textareaProps onScroll should have worked if `MDEditor` passes it.
|
|
|
|
|
+ // Let's try attaching it to the editor wrapper with capture to be safe.
|
|
|
|
|
+ const editorWrapperRef = useRef(null);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const wrapper = editorWrapperRef.current;
|
|
|
|
|
+ if (!wrapper) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Find the textarea inside the wrapper
|
|
|
|
|
+ const textarea = wrapper.querySelector('textarea');
|
|
|
|
|
+ if (!textarea) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Attach scroll listener manually to ensure it's registered
|
|
|
|
|
+ const handleScroll = (e) => handleEditorScroll(e);
|
|
|
|
|
+ textarea.addEventListener('scroll', handleScroll);
|
|
|
|
|
+ return () => textarea.removeEventListener('scroll', handleScroll);
|
|
|
|
|
+ }, [viewMode]); // Re-attach if viewMode changes (though refs persist)
|
|
|
|
|
+
|
|
|
// Auto-resize title textarea
|
|
// Auto-resize title textarea
|
|
|
const titleRef = React.useRef(null);
|
|
const titleRef = React.useRef(null);
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
@@ -475,115 +540,117 @@ function PostEditor() {
|
|
|
{/* Split Editor/Preview Layout */}
|
|
{/* Split Editor/Preview Layout */}
|
|
|
<div className={`grid gap-6 ${viewMode === "split" ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1"}`}>
|
|
<div className={`grid gap-6 ${viewMode === "split" ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1"}`}>
|
|
|
|
|
|
|
|
- {/* Editor Column - Visible if write tab active OR split mode */}
|
|
|
|
|
- {activeTab === "write" || viewMode === "split" ? (
|
|
|
|
|
- <div className="prose-editor-wrapper -mx-4 md:mx-0">
|
|
|
|
|
- <MDEditor
|
|
|
|
|
- value={formData.content}
|
|
|
|
|
- onChange={handleEditorChange}
|
|
|
|
|
- commands={[...commands.getCommands(), ...customCommands]}
|
|
|
|
|
- height={viewMode === "split" ? window.innerHeight * 0.8 : window.innerHeight * 0.7}
|
|
|
|
|
- minHeight={500}
|
|
|
|
|
- preview="edit"
|
|
|
|
|
- visibleDragBar={false}
|
|
|
|
|
- textareaProps={{
|
|
|
|
|
- placeholder: "Tell your story...",
|
|
|
|
|
- onPaste: handlePaste,
|
|
|
|
|
- onSelect: handleCursorSelect,
|
|
|
|
|
- onClick: handleCursorSelect,
|
|
|
|
|
- onKeyUp: handleCursorSelect
|
|
|
|
|
- }}
|
|
|
|
|
- className="shadow-sm"
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : null}
|
|
|
|
|
-
|
|
|
|
|
- {/* Preview Column - Visible if preview tab active OR split mode */}
|
|
|
|
|
- {(activeTab === "preview" || viewMode === "split") && (
|
|
|
|
|
- <article
|
|
|
|
|
- className={`markdown-content prose dark:prose-invert max-w-none prose-lg ${viewMode === "split" ? "border-l pl-6 border-gray-200 dark:border-gray-800" : ""}`}
|
|
|
|
|
- style={{
|
|
|
|
|
- height: viewMode === "split" ? window.innerHeight * 0.8 : 'auto',
|
|
|
|
|
- overflowY: viewMode === "split" ? 'auto' : 'visible'
|
|
|
|
|
|
|
+ {/* Editor Column - Keep always mounted but hidden if needed */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={`prose-editor-wrapper -mx-4 md:mx-0 ${activeTab !== "write" && viewMode !== "split" ? "hidden" : ""}`}
|
|
|
|
|
+ ref={editorWrapperRef}
|
|
|
|
|
+ >
|
|
|
|
|
+ <MDEditor
|
|
|
|
|
+ value={formData.content}
|
|
|
|
|
+ onChange={handleEditorChange}
|
|
|
|
|
+ commands={[...commands.getCommands(), ...customCommands]}
|
|
|
|
|
+ height={viewMode === "split" ? window.innerHeight * 0.8 : window.innerHeight * 0.7}
|
|
|
|
|
+ minHeight={500}
|
|
|
|
|
+ preview="edit"
|
|
|
|
|
+ visibleDragBar={false}
|
|
|
|
|
+ textareaProps={{
|
|
|
|
|
+ placeholder: "Tell your story...",
|
|
|
|
|
+ onPaste: handlePaste,
|
|
|
|
|
+ onSelect: handleCursorSelect,
|
|
|
|
|
+ onClick: handleCursorSelect,
|
|
|
|
|
+ onKeyUp: handleCursorSelect,
|
|
|
|
|
+ // onScroll handled by manual listener on ref
|
|
|
}}
|
|
}}
|
|
|
- >
|
|
|
|
|
- <div
|
|
|
|
|
- dangerouslySetInnerHTML={{ __html: sanitizedPreview }}
|
|
|
|
|
- ref={(node) => {
|
|
|
|
|
- if (node) {
|
|
|
|
|
- // Initialize comparison sliders if present in preview
|
|
|
|
|
- const sliders = node.querySelectorAll(".comparison-slider");
|
|
|
|
|
- sliders.forEach(slider => {
|
|
|
|
|
- slider.oninput = (e) => {
|
|
|
|
|
- const container = e.target.closest(".comparison-wrapper");
|
|
|
|
|
- const topImage = container.querySelector(".comparison-top");
|
|
|
|
|
- const handle = container.querySelector(".slider-handle");
|
|
|
|
|
- const val = e.target.value;
|
|
|
|
|
-
|
|
|
|
|
- if (topImage) topImage.style.clipPath = `inset(0 ${100 - val}% 0 0)`;
|
|
|
|
|
- if (handle) handle.style.left = `${val}%`;
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
- // Initialize Zoom Reels in Preview (Re-using logic from PostView slightly simplified)
|
|
|
|
|
- const zoomReels = node.querySelectorAll(".interactive-zoom-reel");
|
|
|
|
|
- zoomReels.forEach(reel => {
|
|
|
|
|
- const images = reel.querySelectorAll(".zoom-reel-img");
|
|
|
|
|
- const viewports = reel.querySelectorAll(".zoom-reel-viewport");
|
|
|
|
|
- const slider = reel.querySelector(".zoom-slider");
|
|
|
|
|
- const resetBtn = reel.querySelector(".reset-zoom");
|
|
|
|
|
-
|
|
|
|
|
- let state = { zoom: 1, panX: 0, panY: 0, isDragging: false, startX: 0, startY: 0, initialPanX: 0, initialPanY: 0 };
|
|
|
|
|
- const updateTransform = () => {
|
|
|
|
|
- images.forEach(img => {
|
|
|
|
|
- img.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.zoom})`;
|
|
|
|
|
- });
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- slider.oninput = (e) => {
|
|
|
|
|
- state.zoom = parseFloat(e.target.value);
|
|
|
|
|
- if (state.zoom === 1) { state.panX = 0; state.panY = 0; }
|
|
|
|
|
- updateTransform();
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- resetBtn.onclick = () => {
|
|
|
|
|
- state.zoom = 1; state.panX = 0; state.panY = 0; slider.value = 1;
|
|
|
|
|
- updateTransform();
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // Simplified drag for preview
|
|
|
|
|
- reel.onmousedown = (e) => {
|
|
|
|
|
- if (state.zoom <= 1) return;
|
|
|
|
|
- e.preventDefault();
|
|
|
|
|
- state.isDragging = true;
|
|
|
|
|
- state.startX = e.clientX;
|
|
|
|
|
- state.startY = e.clientY;
|
|
|
|
|
- state.initialPanX = state.panX;
|
|
|
|
|
- state.initialPanY = state.panY;
|
|
|
|
|
- viewports.forEach(v => v.style.cursor = "grabbing");
|
|
|
|
|
- };
|
|
|
|
|
- reel.onmousemove = (e) => {
|
|
|
|
|
- if (!state.isDragging) return;
|
|
|
|
|
- e.preventDefault();
|
|
|
|
|
- const dx = e.clientX - state.startX;
|
|
|
|
|
- const dy = e.clientY - state.startY;
|
|
|
|
|
- state.panX = state.initialPanX + dx;
|
|
|
|
|
- state.panY = state.initialPanY + dy;
|
|
|
|
|
- updateTransform();
|
|
|
|
|
- };
|
|
|
|
|
- reel.onmouseup = () => {
|
|
|
|
|
- state.isDragging = false;
|
|
|
|
|
- viewports.forEach(v => v.style.cursor = "grab");
|
|
|
|
|
- };
|
|
|
|
|
- reel.onmouseleave = () => {
|
|
|
|
|
- state.isDragging = false;
|
|
|
|
|
- viewports.forEach(v => v.style.cursor = "grab");
|
|
|
|
|
- };
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- }}
|
|
|
|
|
- />
|
|
|
|
|
- </article>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ className="shadow-sm"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Preview Column - Keep always mounted but hidden if needed */}
|
|
|
|
|
+ <article
|
|
|
|
|
+ ref={previewRef}
|
|
|
|
|
+ onScroll={handlePreviewScroll}
|
|
|
|
|
+ className={`markdown-content prose dark:prose-invert max-w-none prose-lg ${viewMode === "split" ? "border-l pl-6 border-gray-200 dark:border-gray-800" : ""} ${activeTab !== "preview" && viewMode !== "split" ? "hidden" : ""}`}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ height: viewMode === "split" ? window.innerHeight * 0.8 : 'auto',
|
|
|
|
|
+ overflowY: viewMode === "split" ? 'auto' : 'visible'
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ dangerouslySetInnerHTML={{ __html: sanitizedPreview }}
|
|
|
|
|
+ ref={(node) => {
|
|
|
|
|
+ if (node) {
|
|
|
|
|
+ // Initialize comparison sliders if present in preview
|
|
|
|
|
+ const sliders = node.querySelectorAll(".comparison-slider");
|
|
|
|
|
+ sliders.forEach(slider => {
|
|
|
|
|
+ slider.oninput = (e) => {
|
|
|
|
|
+ const container = e.target.closest(".comparison-wrapper");
|
|
|
|
|
+ const topImage = container.querySelector(".comparison-top");
|
|
|
|
|
+ const handle = container.querySelector(".slider-handle");
|
|
|
|
|
+ const val = e.target.value;
|
|
|
|
|
+
|
|
|
|
|
+ if (topImage) topImage.style.clipPath = `inset(0 ${100 - val}% 0 0)`;
|
|
|
|
|
+ if (handle) handle.style.left = `${val}%`;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ // Initialize Zoom Reels in Preview (Re-using logic from PostView slightly simplified)
|
|
|
|
|
+ const zoomReels = node.querySelectorAll(".interactive-zoom-reel");
|
|
|
|
|
+ zoomReels.forEach(reel => {
|
|
|
|
|
+ const images = reel.querySelectorAll(".zoom-reel-img");
|
|
|
|
|
+ const viewports = reel.querySelectorAll(".zoom-reel-viewport");
|
|
|
|
|
+ const slider = reel.querySelector(".zoom-slider");
|
|
|
|
|
+ const resetBtn = reel.querySelector(".reset-zoom");
|
|
|
|
|
+
|
|
|
|
|
+ let state = { zoom: 1, panX: 0, panY: 0, isDragging: false, startX: 0, startY: 0, initialPanX: 0, initialPanY: 0 };
|
|
|
|
|
+ const updateTransform = () => {
|
|
|
|
|
+ images.forEach(img => {
|
|
|
|
|
+ img.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.zoom})`;
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ slider.oninput = (e) => {
|
|
|
|
|
+ state.zoom = parseFloat(e.target.value);
|
|
|
|
|
+ if (state.zoom === 1) { state.panX = 0; state.panY = 0; }
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ resetBtn.onclick = () => {
|
|
|
|
|
+ state.zoom = 1; state.panX = 0; state.panY = 0; slider.value = 1;
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Simplified drag for preview
|
|
|
|
|
+ reel.onmousedown = (e) => {
|
|
|
|
|
+ if (state.zoom <= 1) return;
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ state.isDragging = true;
|
|
|
|
|
+ state.startX = e.clientX;
|
|
|
|
|
+ state.startY = e.clientY;
|
|
|
|
|
+ state.initialPanX = state.panX;
|
|
|
|
|
+ state.initialPanY = state.panY;
|
|
|
|
|
+ viewports.forEach(v => v.style.cursor = "grabbing");
|
|
|
|
|
+ };
|
|
|
|
|
+ reel.onmousemove = (e) => {
|
|
|
|
|
+ if (!state.isDragging) return;
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ const dx = e.clientX - state.startX;
|
|
|
|
|
+ const dy = e.clientY - state.startY;
|
|
|
|
|
+ state.panX = state.initialPanX + dx;
|
|
|
|
|
+ state.panY = state.initialPanY + dy;
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ };
|
|
|
|
|
+ reel.onmouseup = () => {
|
|
|
|
|
+ state.isDragging = false;
|
|
|
|
|
+ viewports.forEach(v => v.style.cursor = "grab");
|
|
|
|
|
+ };
|
|
|
|
|
+ reel.onmouseleave = () => {
|
|
|
|
|
+ state.isDragging = false;
|
|
|
|
|
+ viewports.forEach(v => v.style.cursor = "grab");
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </article>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|