diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..8f66d8d3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "mcp__acp__Edit", + "mcp__acp__Write", + "mcp__acp__Bash" + ] + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 127a7e34..441bf8f4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,5 +2,4 @@ Before you pull, have you made sure... - it looks good on both mobile and desktop - it undo's like it ought to - it handles keyboard interactions reasonably well -- it behaves as you would expect if you lock it - no build errors!!! diff --git a/app/[leaflet_id]/Footer.tsx b/app/[leaflet_id]/Footer.tsx index 94a5a9a3..d51cff0d 100644 --- a/app/[leaflet_id]/Footer.tsx +++ b/app/[leaflet_id]/Footer.tsx @@ -8,22 +8,37 @@ import { ShareOptions } from "app/[leaflet_id]/actions/ShareOptions"; import { HomeButton } from "app/[leaflet_id]/actions/HomeButton"; import { PublishButton } from "./actions/PublishButton"; import { useEntitySetContext } from "components/EntitySetProvider"; -import { HelpButton } from "app/[leaflet_id]/actions/HelpButton"; import { Watermark } from "components/Watermark"; import { BackToPubButton } from "./actions/BackToPubButton"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { useIdentityData } from "components/IdentityProvider"; +import { useEntity } from "src/replicache"; +import { block } from "sharp"; +export function hasBlockToolbar(blockType: string | null | undefined) { + return ( + blockType === "text" || + blockType === "heading" || + blockType === "blockquote" || + blockType === "button" || + blockType === "datetime" || + blockType === "image" + ); +} export function LeafletFooter(props: { entityID: string }) { let focusedBlock = useUIState((s) => s.focusedEntity); + let entity_set = useEntitySetContext(); let { identity } = useIdentityData(); let { data: pub } = useLeafletPublicationData(); + let blockType = useEntity(focusedBlock?.entityID || null, "block/type")?.data + .value; return ( {focusedBlock && focusedBlock.entityType == "block" && + hasBlockToolbar(blockType) && entity_set.permissions.write ? (
) : entity_set.permissions.write ? ( diff --git a/app/globals.css b/app/globals.css index 6a447a52..73d26f5b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -274,6 +274,8 @@ pre.shiki { @apply p-2; @apply rounded-md; @apply overflow-auto; + @apply sm:min-h-12; + @apply min-h-10; @media (min-width: 640px) { @apply p-3; diff --git a/app/lish/[did]/[publication]/icon/route.ts b/app/lish/[did]/[publication]/icon/route.ts index ebfdbe82..2a38ab79 100644 --- a/app/lish/[did]/[publication]/icon/route.ts +++ b/app/lish/[did]/[publication]/icon/route.ts @@ -14,7 +14,6 @@ export async function GET( request: NextRequest, props: { params: Promise<{ did: string; publication: string }> }, ) { - console.log("are we getting here?"); const params = await props.params; try { let did = decodeURIComponent(params.did); diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 64efceec..dbf1e61c 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -33,6 +33,11 @@ import { HorizontalRule } from "./HorizontalRule"; import { deepEquals } from "src/utils/deepEquals"; import { isTextBlock } from "src/utils/isTextBlock"; import { focusPage } from "src/utils/focusPage"; +import { DeleteTiny } from "components/Icons/DeleteTiny"; +import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; +import { Separator } from "components/Layout"; +import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; +import { deleteBlock } from "src/utils/deleteBlock"; export type Block = { factID: string; @@ -63,7 +68,6 @@ export const Block = memo(function Block( // Block handles all block level events like // mouse events, keyboard events and longPress, and setting AreYouSure state // and shared styling like padding and flex for list layouting - let { rep } = useReplicache(); let mouseHandlers = useBlockMouseHandlers(props); let handleDrop = useHandleDrop({ parent: props.parent, @@ -72,7 +76,7 @@ export const Block = memo(function Block( }); let entity_set = useEntitySetContext(); - let { isLongPress, handlers } = useLongPress(() => { + let { isLongPress, longPressHandlers } = useLongPress(() => { if (isTextBlock[props.type]) return; if (isLongPress.current) { focusBlock( @@ -85,6 +89,20 @@ export const Block = memo(function Block( let selected = useUIState( (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), ); + let alignment = useEntity(props.value, "block/text-alignment")?.data.value; + + let alignmentStyle = + props.type === "button" || props.type === "image" + ? "justify-center" + : "justify-start"; + + if (alignment) + alignmentStyle = { + left: "justify-start", + right: "justify-end", + center: "justify-center", + justify: "justify-start", + }[alignment]; let [areYouSure, setAreYouSure] = useState(false); useEffect(() => { @@ -98,7 +116,7 @@ export const Block = memo(function Block( return (
{ // BaseBlock renders the actual block content, delete states, controls spacing between block and list markers let BlockTypeComponent = BlockTypeComponents[props.type]; - let alignment = useEntity(props.value, "block/text-alignment")?.data.value; - - let alignmentStyle = - props.type === "button" || props.type === "image" - ? "justify-center" - : "justify-start"; - - if (alignment) - alignmentStyle = { - left: "justify-start", - right: "justify-end", - center: "justify-center", - justify: "justify-start", - }[alignment]; if (!BlockTypeComponent) return
unknown block
; return ( -
+ <> {props.listData && } {props.areYouSure ? ( )} -
+ ); }; @@ -326,11 +330,6 @@ export const BlockMultiselectIndicator = (props: BlockProps) => { s.selectedBlocks.length > 1, ); - let isSelected = useUIState((s) => - s.selectedBlocks.find((b) => b.value === props.entityID), - ); - let isLocked = useEntity(props.value, "block/is-locked"); - let nextBlockSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), ); @@ -338,10 +337,7 @@ export const BlockMultiselectIndicator = (props: BlockProps) => { s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), ); - if (isMultiselected || (isLocked?.data.value && isSelected)) - // not sure what multiselected and selected classes are doing (?) - // use a hashed pattern for locked things. show this pattern if the block is selected, even if it isn't multiselected - + if (isMultiselected) return ( <>
{ ${!prevBlockSelected && "rounded-t-md"} ${!nextBlockSelected && "rounded-b-md"} `} - style={ - isLocked?.data.value - ? { - maskImage: "var(--hatchSVG)", - maskRepeat: "repeat repeat", - } - : {} - } - >
- {isLocked?.data.value && ( -
- -
- )} + /> ); }; export const BlockLayout = (props: { - isSelected?: boolean; + isSelected: boolean; children: React.ReactNode; className?: string; + optionsClassName?: string; hasBackground?: "accent" | "page"; borderOnHover?: boolean; + hasAlignment?: boolean; + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; }) => { + // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete return (
+
+ {props.children} +
+ {props.isSelected && ( + + )} +
+ ); +}; + +let debounced: null | number = null; + +const NonTextBlockOptions = (props: { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + optionsClassName?: string; +}) => { + let { rep } = useReplicache(); + let entity_set = useEntitySetContext(); + let focusedEntity = useUIState((s) => s.focusedEntity); + let focusedEntityType = useEntity( + focusedEntity?.entityType === "page" + ? focusedEntity.entityID + : focusedEntity?.parent || null, + "page/type", + ); + + let isMultiselected = useUIState((s) => s.selectedBlocks.length > 1); + if (focusedEntity?.entityType === "page") return; + + if (isMultiselected) return; + + return ( +
- {props.children} + {focusedEntityType?.data.value !== "canvas" && ( + <> + + + + + )} +
); }; diff --git a/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx index 33a427ac..2e4703a9 100644 --- a/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx @@ -18,7 +18,6 @@ export const BlueskyPostEmpty = (props: BlockProps) => { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); - let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; let entity_set = useEntitySetContext(); let [urlValue, setUrlValue] = useState(""); @@ -91,7 +90,6 @@ export const BlueskyPostEmpty = (props: BlockProps) => { className="w-full grow border-none outline-hidden bg-transparent " placeholder="bsky.app/post-url" value={urlValue} - disabled={isLocked} onChange={(e) => setUrlValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { @@ -109,7 +107,7 @@ export const BlueskyPostEmpty = (props: BlockProps) => {
+ {isLocalUpload || image.data.local ? ( )} {altText !== undefined && !props.preview ? ( @@ -204,32 +207,38 @@ const CoverImageButton = (props: { entityID: string }) => { ); // Only show if focused, in a publication, has write permissions, and no cover image is set - if ( - !isFocused || - !pubData?.publications || - !entity_set.permissions.write || - coverImage - ) + if (!isFocused || !pubData?.publications || !entity_set.permissions.write) return null; - - return ( -
- -
+ Remove Cover Image + + + ); + return ( + { + e.preventDefault(); + e.stopPropagation(); + await rep?.mutate.updatePublicationDraft({ + cover_image: props.entityID, + }); + }} + > + Use as Cover Image + + ); }; diff --git a/components/Blocks/MailboxBlock.tsx b/components/Blocks/MailboxBlock.tsx index 1bc591cd..670affae 100644 --- a/components/Blocks/MailboxBlock.tsx +++ b/components/Blocks/MailboxBlock.tsx @@ -26,7 +26,12 @@ import { usePageTitle } from "components/utils/UpdateLeafletTitle"; import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; import { InfoSmall } from "components/Icons/InfoSmall"; -export const MailboxBlock = (props: BlockProps) => { +export const MailboxBlock = ( + props: BlockProps & { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) => { let isSubscribed = useSubscriptionStatus(props.entityID); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), @@ -41,7 +46,10 @@ export const MailboxBlock = (props: BlockProps) => { let subscriber_count = useEntity(props.entityID, "mailbox/subscriber-count"); if (!permission) return ( - + ); return ( @@ -49,6 +57,8 @@ export const MailboxBlock = (props: BlockProps) => { { ); }; -const MailboxReaderView = (props: { entityID: string; parent: string }) => { +const MailboxReaderView = (props: { + entityID: string; + parent: string; + +}) => { let isSubscribed = useSubscriptionStatus(props.entityID); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), @@ -133,6 +147,7 @@ const MailboxReaderView = (props: { entityID: string; parent: string }) => { {!isSubscribed?.confirmed ? ( diff --git a/components/Blocks/PageLinkBlock.tsx b/components/Blocks/PageLinkBlock.tsx index df27bd24..90474ea4 100644 --- a/components/Blocks/PageLinkBlock.tsx +++ b/components/Blocks/PageLinkBlock.tsx @@ -13,7 +13,13 @@ import { Canvas, CanvasBackground, CanvasContent } from "components/Canvas"; import { CardThemeProvider } from "components/ThemeManager/ThemeProvider"; import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; -export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { +export function PageLinkBlock( + props: BlockProps & { + preview?: boolean; + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) { let page = useEntity(props.entityID, "block/card"); let type = useEntity(page?.data.value || null, "page/type")?.data.value || "doc"; @@ -32,6 +38,8 @@ export function PageLinkBlock(props: BlockProps & { preview?: boolean }) { { +export const PollBlock = ( + props: BlockProps & { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) => { let { data: pub } = useLeafletPublicationData(); if (!pub) return ; return ; }; -export const LeafletPollBlock = (props: BlockProps) => { +export const LeafletPollBlock = ( + props: BlockProps & { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) => { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); @@ -64,6 +74,8 @@ export const LeafletPollBlock = (props: BlockProps) => { {pollState === "editing" ? ( diff --git a/components/Blocks/PublicationPollBlock.tsx b/components/Blocks/PublicationPollBlock.tsx index f776b566..e7fcf940 100644 --- a/components/Blocks/PublicationPollBlock.tsx +++ b/components/Blocks/PublicationPollBlock.tsx @@ -21,8 +21,13 @@ import { ids } from "lexicons/api/lexicons"; * It allows adding/editing options when the poll hasn't been published yet, * but disables adding new options once the poll record exists (indicated by pollUri). */ -export const PublicationPollBlock = (props: BlockProps) => { - let { data: publicationData, normalizedDocument } = useLeafletPublicationData(); +export const PublicationPollBlock = ( + props: BlockProps & { + areYouSure?: boolean; + setAreYouSure?: (value: boolean) => void; + }, +) => { + let { data: publicationData } = useLeafletPublicationData(); let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); @@ -57,6 +62,8 @@ export const PublicationPollBlock = (props: BlockProps) => { className="poll flex flex-col gap-2" hasBackground={"accent"} isSelected={!!isSelected} + areYouSure={props.areYouSure} + setAreYouSure={props.setAreYouSure} > void; + }, +) { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), ); @@ -32,6 +37,8 @@ export function RSVPBlock(props: BlockProps) { diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index f774469b..3bfd54c0 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -41,17 +41,13 @@ export function TextBlock( preview?: boolean; }, ) { - let isLocked = useEntity(props.entityID, "block/is-locked"); let initialized = useHasPageLoaded(); let first = props.previousBlock === null; let permission = useEntitySetContext().permissions.write; return ( <> - {(!initialized || - !permission || - props.preview || - isLocked?.data.value) && ( + {(!initialized || !permission || props.preview) && ( )} - {permission && !props.preview && !isLocked?.data.value && ( + {permission && !props.preview && (
@@ -330,7 +326,6 @@ const BlockifyLink = (props: { let { editorState } = props; let rep = useReplicache(); let smoker = useSmoker(); - let isLocked = useEntity(props.entityID, "block/is-locked"); let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); let isBlueskyPost = @@ -340,7 +335,6 @@ const BlockifyLink = (props: { // if its bluesky, change text to embed post if ( - !isLocked && focused && editorState && betterIsUrl(editorState.doc.textContent) && diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx index 6a7414b6..f5d49802 100644 --- a/components/Blocks/index.tsx +++ b/components/Blocks/index.tsx @@ -181,11 +181,9 @@ function NewBlockButton(props: { lastBlock: Block | null; entityID: string }) { : null, ); - let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); if (!entity_set.permissions.write) return null; if ( - ((props.lastBlock?.type === "text" && !isLocked?.data.value) || - props.lastBlock?.type === "heading") && + (props.lastBlock?.type === "text" || props.lastBlock?.type === "heading") && (!editorState?.editor || editorState.editor.doc.content.size <= 2) ) return null; diff --git a/components/Blocks/useBlockKeyboardHandlers.ts b/components/Blocks/useBlockKeyboardHandlers.ts index 18b1f43b..6ffb977e 100644 --- a/components/Blocks/useBlockKeyboardHandlers.ts +++ b/components/Blocks/useBlockKeyboardHandlers.ts @@ -23,7 +23,6 @@ export function useBlockKeyboardHandlers( ) { let { rep, undoManager } = useReplicache(); let entity_set = useEntitySetContext(); - let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value; let isSelected = useUIState((s) => { let selectedBlocks = s.selectedBlocks; @@ -70,18 +69,16 @@ export function useBlockKeyboardHandlers( entity_set, areYouSure, setAreYouSure, - isLocked, }); undoManager.endGroup(); }; window.addEventListener("keydown", listener); return () => window.removeEventListener("keydown", listener); - }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure, isLocked]); + }, [entity_set, isSelected, props, rep, areYouSure, setAreYouSure]); } type Args = { e: KeyboardEvent; - isLocked: boolean; props: BlockProps; rep: Replicache; entity_set: { set: string }; @@ -133,16 +130,8 @@ function ArrowUp({ e, props }: Args) { } let debounced: null | number = null; -async function Backspace({ - e, - props, - rep, - areYouSure, - setAreYouSure, - isLocked, -}: Args) { +async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { // if this is a textBlock, let the textBlock/keymap handle the backspace - if (isLocked) return; // if its an input, label, or teatarea with content, do nothing (do the broswer default instead) let el = e.target as HTMLElement; if ( @@ -154,11 +143,12 @@ async function Backspace({ if ((el as HTMLInputElement).value !== "") return; } - // if the block is a card or mailbox... + // if the block is a card, mailbox, rsvp, or poll... if ( props.type === "card" || props.type === "mailbox" || - props.type === "rsvp" + props.type === "rsvp" || + props.type === "poll" ) { // ...and areYouSure state is false, set it to true if (!areYouSure) { diff --git a/components/Blocks/useBlockMouseHandlers.ts b/components/Blocks/useBlockMouseHandlers.ts index eca3cc68..ddface11 100644 --- a/components/Blocks/useBlockMouseHandlers.ts +++ b/components/Blocks/useBlockMouseHandlers.ts @@ -1,5 +1,5 @@ import { useSelectingMouse } from "components/SelectionManager/selectionState"; -import { MouseEvent, useCallback, useRef } from "react"; +import { MouseEvent, useCallback } from "react"; import { useUIState } from "src/useUIState"; import { Block } from "./Block"; import { isTextBlock } from "src/utils/isTextBlock"; @@ -12,6 +12,24 @@ import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; import { elementId } from "src/utils/elementId"; let debounce: number | null = null; + +// Track scrolling state for mobile +let isScrolling = false; +let scrollTimeout: number | null = null; + +if (typeof window !== "undefined") { + window.addEventListener( + "scroll", + () => { + isScrolling = true; + if (scrollTimeout) window.clearTimeout(scrollTimeout); + scrollTimeout = window.setTimeout(() => { + isScrolling = false; + }, 150); + }, + true, + ); +} export function useBlockMouseHandlers(props: Block) { let entity_set = useEntitySetContext(); let isMobile = useIsMobile(); @@ -22,7 +40,7 @@ export function useBlockMouseHandlers(props: Block) { if ((e.target as Element).tagName === "BUTTON") return; if ((e.target as Element).tagName === "SELECT") return; if ((e.target as Element).tagName === "OPTION") return; - if (isMobile) return; + if (isMobile && isScrolling) return; if (!entity_set.permissions.write) return; useSelectingMouse.setState({ start: props.value }); if (e.shiftKey) { @@ -57,7 +75,7 @@ export function useBlockMouseHandlers(props: Block) { ); let onMouseEnter = useCallback( async (e: MouseEvent) => { - if (isMobile) return; + if (isMobile && isScrolling) return; if (!entity_set.permissions.write) return; if (debounce) window.clearTimeout(debounce); debounce = window.setTimeout(async () => { diff --git a/components/Canvas.tsx b/components/Canvas.tsx index dffb36b1..0faa3302 100644 --- a/components/Canvas.tsx +++ b/components/Canvas.tsx @@ -22,6 +22,7 @@ import { QuoteTiny } from "./Icons/QuoteTiny"; import { PublicationMetadata } from "./Pages/PublicationMetadata"; import { useLeafletPublicationData } from "./PageSWRDataProvider"; import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop"; +import { useBlockMouseHandlers } from "./Blocks/useBlockMouseHandlers"; export function Canvas(props: { entityID: string; @@ -286,9 +287,8 @@ function CanvasBlock(props: { }, [props, rep, permissions], ); - let { dragDelta, handlers } = useDrag({ + let { dragDelta, handlers: dragHandlers } = useDrag({ onDragEnd, - delay: isMobile, }); let widthOnDragEnd = useCallback( @@ -335,18 +335,20 @@ function CanvasBlock(props: { ); let rotateHandle = useDrag({ onDragEnd: RotateOnDragEnd }); - let { isLongPress, handlers: longPressHandlers } = useLongPress(() => { - if (isLongPress.current && permissions.write) { - focusBlock( - { - type: type?.data.value || "text", - value: props.entityID, - parent: props.parent, - }, - { type: "start" }, - ); - } - }); + let { isLongPress, longPressHandlers: longPressHandlers } = useLongPress( + () => { + if (isLongPress.current && permissions.write) { + focusBlock( + { + type: type?.data.value || "text", + value: props.entityID, + parent: props.parent, + }, + { type: "start" }, + ); + } + }, + ); let angle = 0; if (rotateHandle.dragDelta) { let originX = rect.x + rect.width / 2; @@ -383,6 +385,8 @@ function CanvasBlock(props: { }; }, [props, type?.data.value]); useBlockKeyboardHandlers(blockProps, areYouSure, setAreYouSure); + let mouseHandlers = useBlockMouseHandlers(blockProps); + let isList = useEntity(props.entityID, "block/is-list"); let isFocused = useUIState( (s) => s.focusedEntity?.entityID === props.entityID, @@ -391,10 +395,9 @@ function CanvasBlock(props: { return (
- {/* the gripper show on hover, but longpress logic needs to be added for mobile*/} - {!props.preview && permissions.write && } + {!props.preview && permissions.write && ( + + )} +