mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-11-27 19:56:12 +03:00
New feature: pop-out compose window
- More consistent design for both reply-to status and source status preview - Fixed bugs too - Make sure index.css is always above
This commit is contained in:
parent
3e80ee03f3
commit
9d78e67381
10 changed files with 329 additions and 50 deletions
|
@ -3,7 +3,13 @@
|
|||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"importOrder": [".css$", "<THIRD_PARTY_MODULES>", "^../", "^[./]"],
|
||||
"importOrder": [
|
||||
"index.css$",
|
||||
".css$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^../",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"importOrderGroupNamespaceSpecifiers": true,
|
||||
|
|
14
compose/index.html
Normal file
14
compose/index.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Compose / Phanpy</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/compose.jsx"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -23,7 +23,7 @@ import store from './utils/store';
|
|||
|
||||
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||
|
||||
window._STATES = states;
|
||||
window.__STATES__ = states;
|
||||
|
||||
async function startStream() {
|
||||
const stream = await masto.stream.streamUser();
|
||||
|
@ -267,6 +267,7 @@ export function App() {
|
|||
: null
|
||||
}
|
||||
editStatus={snapStates.showCompose?.editStatus || null}
|
||||
draftStatus={snapStates.showCompose?.draftStatus || null}
|
||||
onClose={(result) => {
|
||||
states.showCompose = false;
|
||||
if (result) {
|
||||
|
|
|
@ -19,11 +19,6 @@
|
|||
z-index: 100;
|
||||
}
|
||||
|
||||
#compose-container .close-button {
|
||||
padding: 6px;
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
|
||||
#compose-container textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
@ -42,18 +37,21 @@
|
|||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
#compose-container .reply-to {
|
||||
#compose-container .status-preview {
|
||||
border-radius: 8px 8px 0 0;
|
||||
max-height: 160px;
|
||||
pointer-events: none;
|
||||
filter: saturate(0.25) opacity(0.75);
|
||||
background-color: var(--bg-blur-color);
|
||||
background-color: var(--bg-color);
|
||||
margin: 0 12px;
|
||||
border: 1px solid var(--outline-color);
|
||||
border-bottom: 0;
|
||||
/* box-shadow: 0 0 12px var(--divider-color); */
|
||||
/* mask-image: linear-gradient(rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 90%, transparent); */
|
||||
animation: appear-up 1s ease-in-out;
|
||||
overflow: auto;
|
||||
}
|
||||
#compose-container.standalone .status-preview * {
|
||||
/*
|
||||
For standalone mode (new window), prevent interacting with the status preview for now
|
||||
*/
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes appear-down {
|
||||
0% {
|
||||
|
@ -63,6 +61,38 @@
|
|||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
#compose-container .status-preview-legend {
|
||||
pointer-events: none;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 8px;
|
||||
font-size: 80%;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: var(--text-insignificant-color);
|
||||
background-color: var(--bg-blur-color);
|
||||
/* background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--bg-faded-color)
|
||||
); */
|
||||
border-top: 1px solid var(--outline-color);
|
||||
backdrop-filter: blur(8px);
|
||||
text-shadow: 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
||||
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
||||
0 1px 10px var(--bg-color);
|
||||
}
|
||||
#_compose-container .status-preview-legend.reply-to {
|
||||
color: var(--reply-to-color);
|
||||
background-color: var(--reply-to-faded-color);
|
||||
/* background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--reply-to-faded-color)
|
||||
); */
|
||||
}
|
||||
|
||||
#compose-container form {
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
|
@ -70,7 +100,7 @@
|
|||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
#compose-container .reply-to ~ form {
|
||||
#compose-container .status-preview ~ form {
|
||||
animation: appear-down 1s ease-in-out;
|
||||
box-shadow: 0 -12px 12px -12px var(--divider-color);
|
||||
}
|
||||
|
|
|
@ -17,7 +17,13 @@ import Status from './status';
|
|||
- Max character limit includes BOTH status text and Content Warning text
|
||||
*/
|
||||
|
||||
export default ({ onClose, replyToStatus, editStatus }) => {
|
||||
export default ({
|
||||
onClose,
|
||||
replyToStatus,
|
||||
editStatus,
|
||||
draftStatus,
|
||||
standalone,
|
||||
}) => {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
||||
const accounts = store.local.getJSON('accounts');
|
||||
|
@ -51,27 +57,34 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
|
||||
const textareaRef = useRef();
|
||||
|
||||
const [visibility, setVisibility] = useState(
|
||||
replyToStatus?.visibility || 'public',
|
||||
);
|
||||
const [sensitive, setSensitive] = useState(replyToStatus?.sensitive || false);
|
||||
const [visibility, setVisibility] = useState('public');
|
||||
const [sensitive, setSensitive] = useState(false);
|
||||
const spoilerTextRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
let timer = setTimeout(() => {
|
||||
const spoilerText = replyToStatus?.spoilerText;
|
||||
if (replyToStatus) {
|
||||
const { spoilerText, visibility, sensitive } = replyToStatus;
|
||||
if (spoilerText && spoilerTextRef.current) {
|
||||
spoilerTextRef.current.value = spoilerText;
|
||||
spoilerTextRef.current.focus();
|
||||
} else {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current.focus();
|
||||
if (replyToStatus.account.id !== currentAccount) {
|
||||
textareaRef.current.value = `@${replyToStatus.account.acct} `;
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editStatus) {
|
||||
}
|
||||
setVisibility(visibility);
|
||||
setSensitive(sensitive);
|
||||
}
|
||||
if (draftStatus) {
|
||||
const { status, spoilerText, visibility, sensitive, mediaAttachments } =
|
||||
draftStatus;
|
||||
textareaRef.current.value = status;
|
||||
spoilerTextRef.current.value = spoilerText;
|
||||
setVisibility(visibility);
|
||||
setSensitive(sensitive);
|
||||
setMediaAttachments(mediaAttachments);
|
||||
} else if (editStatus) {
|
||||
const { visibility, sensitive, mediaAttachments } = editStatus;
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
|
@ -93,7 +106,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
}
|
||||
})();
|
||||
}
|
||||
}, [editStatus]);
|
||||
}, [draftStatus, editStatus, replyToStatus]);
|
||||
|
||||
const textExpanderRef = useRef();
|
||||
const textExpanderTextRef = useRef('');
|
||||
|
@ -192,13 +205,32 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
const beforeUnloadCopy =
|
||||
'You have unsaved changes. Are you sure you want to discard this post?';
|
||||
const canClose = () => {
|
||||
// check for status or mediaAttachments
|
||||
const { value, dataset } = textareaRef.current;
|
||||
const containNonIDMediaAttachments =
|
||||
|
||||
// check for non-ID media attachments
|
||||
const hasNonIDMediaAttachments =
|
||||
mediaAttachments.length > 0 &&
|
||||
mediaAttachments.some((media) => !media.id);
|
||||
|
||||
if ((value && value !== dataset?.source) || containNonIDMediaAttachments) {
|
||||
// check if status contains only "@acct", if replying
|
||||
const hasAcct =
|
||||
replyToStatus && value.trim() === `@${replyToStatus.account.acct}`;
|
||||
|
||||
// check if status is different than source
|
||||
const differentThanSource = dataset?.source && value !== dataset.source;
|
||||
|
||||
console.log({
|
||||
value,
|
||||
hasAcct,
|
||||
differentThanSource,
|
||||
hasNonIDMediaAttachments,
|
||||
});
|
||||
|
||||
if (
|
||||
(value && !hasAcct) ||
|
||||
differentThanSource ||
|
||||
hasNonIDMediaAttachments
|
||||
) {
|
||||
const yes = confirm(beforeUnloadCopy);
|
||||
return yes;
|
||||
}
|
||||
|
@ -223,7 +255,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div id="compose-container">
|
||||
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
||||
<div class="compose-top">
|
||||
{currentAccountInfo?.avatarStatic && (
|
||||
<Avatar
|
||||
|
@ -232,6 +264,66 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
alt={currentAccountInfo.username}
|
||||
/>
|
||||
)}
|
||||
{!standalone ? (
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
onClick={() => {
|
||||
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
|
||||
const containNonIDMediaAttachments =
|
||||
mediaAttachments.length > 0 &&
|
||||
mediaAttachments.some((media) => !media.id);
|
||||
if (containNonIDMediaAttachments) {
|
||||
const yes = confirm(
|
||||
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
|
||||
);
|
||||
if (!yes) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL('/compose/', window.location);
|
||||
const screenWidth = window.screen.width;
|
||||
const screenHeight = window.screen.height;
|
||||
const left = Math.max(0, (screenWidth - 600) / 2);
|
||||
const top = Math.max(0, (screenHeight - 450) / 2);
|
||||
const width = Math.min(screenWidth, 600);
|
||||
const height = Math.min(screenHeight, 450);
|
||||
const newWin = window.open(
|
||||
url,
|
||||
'compose' + Math.random(),
|
||||
`width=${width},height=${height},left=${left},top=${top}`,
|
||||
);
|
||||
|
||||
if (!newWin) {
|
||||
alert('Looks like your browser is blocking popups.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAttachmentsWithIDs = mediaAttachments.filter(
|
||||
(media) => media.id,
|
||||
);
|
||||
|
||||
newWin.masto = masto;
|
||||
newWin.__COMPOSE__ = {
|
||||
editStatus,
|
||||
replyToStatus,
|
||||
draftStatus: {
|
||||
status: textareaRef.current.value,
|
||||
spoilerText: spoilerTextRef.current.value,
|
||||
visibility,
|
||||
sensitive,
|
||||
mediaAttachments: mediaAttachmentsWithIDs,
|
||||
},
|
||||
};
|
||||
onClose(() => {
|
||||
window.opener.__STATES__.reloadStatusPage++;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="popout" alt="Pop out" />
|
||||
</button>{' '}
|
||||
<button
|
||||
type="button"
|
||||
class="light close-button"
|
||||
|
@ -243,10 +335,66 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
onClick={() => {
|
||||
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
|
||||
const containNonIDMediaAttachments =
|
||||
mediaAttachments.length > 0 &&
|
||||
mediaAttachments.some((media) => !media.id);
|
||||
if (containNonIDMediaAttachments) {
|
||||
const yes = confirm(
|
||||
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
|
||||
);
|
||||
if (!yes) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.opener) {
|
||||
alert('Looks like you closed the parent window.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAttachmentsWithIDs = mediaAttachments.filter(
|
||||
(media) => media.id,
|
||||
);
|
||||
|
||||
onClose(() => {
|
||||
window.opener.__STATES__.showCompose = {
|
||||
editStatus,
|
||||
replyToStatus,
|
||||
draftStatus: {
|
||||
status: textareaRef.current.value,
|
||||
spoilerText: spoilerTextRef.current.value,
|
||||
visibility,
|
||||
sensitive,
|
||||
mediaAttachments: mediaAttachmentsWithIDs,
|
||||
},
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="popin" alt="Pop in" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!!replyToStatus && (
|
||||
<div class="reply-to">
|
||||
<div class="status-preview">
|
||||
<Status status={replyToStatus} size="s" />
|
||||
<div class="status-preview-legend reply-to">
|
||||
Replying to @
|
||||
{replyToStatus.account.acct || replyToStatus.account.username}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!editStatus && (
|
||||
<div class="status-preview">
|
||||
<Status status={editStatus} size="s" />
|
||||
<div class="status-preview-legend">Editing source status</div>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
|
@ -385,6 +533,7 @@ export default ({ onClose, replyToStatus, editStatus }) => {
|
|||
<input
|
||||
name="sensitive"
|
||||
type="checkbox"
|
||||
checked={sensitive}
|
||||
disabled={uiState === 'loading' || !!editStatus}
|
||||
onChange={(e) => {
|
||||
const sensitive = e.target.checked;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'iconify-icon';
|
||||
|
||||
const SIZES = {
|
||||
s: 12,
|
||||
m: 16,
|
||||
|
@ -35,11 +37,18 @@ const ICONS = {
|
|||
upload: 'mingcute:upload-3-line',
|
||||
gear: 'mingcute:settings-3-line',
|
||||
more: 'mingcute:more-1-line',
|
||||
external: 'mingcute:external-link-line',
|
||||
popout: 'mingcute:external-link-line',
|
||||
popin: ['mingcute:external-link-line', '180deg'],
|
||||
};
|
||||
|
||||
export default ({ icon, size = 'm', alt, title, class: className = '' }) => {
|
||||
const iconSize = SIZES[size];
|
||||
const iconName = ICONS[icon];
|
||||
let iconName = ICONS[icon];
|
||||
let rotate;
|
||||
if (Array.isArray(iconName)) {
|
||||
[iconName, rotate] = iconName;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
class={`icon ${className}`}
|
||||
|
@ -52,7 +61,12 @@ export default ({ icon, size = 'm', alt, title, class: className = '' }) => {
|
|||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<iconify-icon width={iconSize} height={iconSize} icon={iconName}>
|
||||
<iconify-icon
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
icon={iconName}
|
||||
rotate={rotate}
|
||||
>
|
||||
{alt}
|
||||
</iconify-icon>
|
||||
</div>
|
||||
|
|
|
@ -93,11 +93,13 @@
|
|||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.status:not(.small) .container {
|
||||
padding-left: 16px;
|
||||
.status .container {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.status:not(.small) .container {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.status > .container > .meta {
|
||||
display: flex;
|
||||
|
|
55
src/compose.jsx
Normal file
55
src/compose.jsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import './index.css';
|
||||
|
||||
import './app.css';
|
||||
|
||||
import '@github/time-elements';
|
||||
import { render } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import Compose from './components/compose';
|
||||
|
||||
function App() {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
||||
const { editStatus, replyToStatus, draftStatus } = window.__COMPOSE__ || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (uiState === 'closed') {
|
||||
window.close();
|
||||
}
|
||||
}, [uiState]);
|
||||
|
||||
if (uiState === 'closed') {
|
||||
return (
|
||||
<div>
|
||||
<p>You may close this page now.</p>
|
||||
<p>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.close();
|
||||
}}
|
||||
>
|
||||
Close window
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Compose
|
||||
editStatus={editStatus}
|
||||
replyToStatus={replyToStatus}
|
||||
draftStatus={draftStatus}
|
||||
standalone
|
||||
onClose={(fn = () => {}) => {
|
||||
try {
|
||||
fn();
|
||||
setUIState('closed');
|
||||
} catch (e) {}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<App />, document.getElementById('app'));
|
|
@ -1,7 +1,6 @@
|
|||
import './index.css';
|
||||
|
||||
import '@github/time-elements';
|
||||
import 'iconify-icon';
|
||||
import { render } from 'preact';
|
||||
|
||||
import { App } from './app';
|
||||
|
|
|
@ -1,7 +1,16 @@
|
|||
import preact from '@preact/preset-vite';
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
compose: resolve(__dirname, 'compose/index.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue