Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20032be921 | |||
| 2524f39a36 | |||
| ef173f022d | |||
| 38f9a7938a |
@@ -197,6 +197,12 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
|
|
||||||
Run from the repo root unless noted. The dev workflow needs **Postgres (with the `pgvector` extension) and Redis** reachable per `.env` (copy `.env.example` → `.env`).
|
Run from the repo root unless noted. The dev workflow needs **Postgres (with the `pgvector` extension) and Redis** reachable per `.env` (copy `.env.example` → `.env`).
|
||||||
|
|
||||||
|
> **Bringing up a full local stand** (API + client + the separate realtime
|
||||||
|
> collaboration process) has several non-obvious gotchas — a missing collab
|
||||||
|
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
||||||
|
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||||
|
> for the step-by-step and the traps.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||||
pnpm dev # client (Vite) + server (Nest watch) concurrently — primary dev loop
|
pnpm dev # client (Vite) + server (Nest watch) concurrently — primary dev loop
|
||||||
@@ -241,6 +247,8 @@ Migration files live in `apps/server/src/database/migrations/` and are named `YY
|
|||||||
- **API server** — `dist/main` (`apps/server/src/main.ts`), the Fastify HTTP app (`AppModule`).
|
- **API server** — `dist/main` (`apps/server/src/main.ts`), the Fastify HTTP app (`AppModule`).
|
||||||
- **Collaboration server** — `dist/collaboration/server/collab-main` (`pnpm collab`), a Hocuspocus/Yjs WebSocket server (`apps/server/src/collaboration/`) handling real-time document editing, persistence, and page-history snapshots. It listens on `COLLAB_PORT` (default `3001`), separate from the API server's `PORT` (default `3000`), and shares state with the API server through Redis.
|
- **Collaboration server** — `dist/collaboration/server/collab-main` (`pnpm collab`), a Hocuspocus/Yjs WebSocket server (`apps/server/src/collaboration/`) handling real-time document editing, persistence, and page-history snapshots. It listens on `COLLAB_PORT` (default `3001`), separate from the API server's `PORT` (default `3000`), and shares state with the API server through Redis.
|
||||||
|
|
||||||
|
`pnpm dev` starts **only** the API server + client — the collaboration process is separate and must be started too, or the editor never connects. See **[docs/dev-stand.md](docs/dev-stand.md)** for running both locally (and why `APP_SECRET` must match between them).
|
||||||
|
|
||||||
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). `GET /api/sb/:id` (the anonymous blob-sandbox read route) is listed in that preHandler's `excludedPaths`, so it is exempt from workspace resolution and carries no session auth at all (its capability is the unguessable UUID + TTL + TLS) — unlike `/api/files/public/...`, which still resolves a workspace and requires a workspace-bound attachment JWT. Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
|
The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes `robots.txt`, public share pages, and `mcp` from the prefix). A `preHandler` hook enforces that a resolved `workspaceId` exists for most `/api` routes (multi-tenant by hostname/subdomain via `DomainMiddleware`). `GET /api/sb/:id` (the anonymous blob-sandbox read route) is listed in that preHandler's `excludedPaths`, so it is exempt from workspace resolution and carries no session auth at all (its capability is the unguessable UUID + TTL + TLS) — unlike `/api/files/public/...`, which still resolves a workspace and requires a workspace-bound attachment JWT. Auth is JWT (cookie + bearer); authorization is **CASL** (`core/casl`) — every data access is scoped to the user's abilities.
|
||||||
|
|
||||||
### Module structure (server)
|
### Module structure (server)
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
|
images as a row that wraps onto the next line on narrow screens. Unlike the
|
||||||
|
float modes, text does not wrap around inline images. The mode round-trips
|
||||||
|
losslessly through markdown as `data-align`, like the other alignment
|
||||||
|
values.
|
||||||
|
|
||||||
- **Editable captions for images.** Images gain an optional caption shown
|
- **Editable captions for images.** Images gain an optional caption shown
|
||||||
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
|
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
|
||||||
losslessly through markdown as a `data-caption` attribute on the image, so
|
losslessly through markdown as a `data-caption` attribute on the image, so
|
||||||
|
|||||||
@@ -1322,6 +1322,7 @@
|
|||||||
"Move to space": "Move to space",
|
"Move to space": "Move to space",
|
||||||
"Float left (wrap text)": "Float left (wrap text)",
|
"Float left (wrap text)": "Float left (wrap text)",
|
||||||
"Float right (wrap text)": "Float right (wrap text)",
|
"Float right (wrap text)": "Float right (wrap text)",
|
||||||
|
"Inline (side by side)": "Inline (side by side)",
|
||||||
"Switch to tree": "Switch to tree",
|
"Switch to tree": "Switch to tree",
|
||||||
"Switch to flat list": "Switch to flat list",
|
"Switch to flat list": "Switch to flat list",
|
||||||
"Toggle subpages display mode": "Toggle subpages display mode",
|
"Toggle subpages display mode": "Toggle subpages display mode",
|
||||||
|
|||||||
@@ -1175,6 +1175,7 @@
|
|||||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||||
"Float left (wrap text)": "Обтекание слева",
|
"Float left (wrap text)": "Обтекание слева",
|
||||||
"Float right (wrap text)": "Обтекание справа",
|
"Float right (wrap text)": "Обтекание справа",
|
||||||
|
"Inline (side by side)": "В ряд",
|
||||||
"Switch to tree": "Переключить на дерево",
|
"Switch to tree": "Переключить на дерево",
|
||||||
"Switch to flat list": "Переключить на плоский список",
|
"Switch to flat list": "Переключить на плоский список",
|
||||||
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
|
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
IconFloatLeft,
|
IconFloatLeft,
|
||||||
IconFloatRight,
|
IconFloatRight,
|
||||||
|
IconLayoutColumns,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -46,6 +47,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||||
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
|
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
|
||||||
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
|
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
|
||||||
|
isInline: ctx.editor.isActive("image", { align: "inline" }),
|
||||||
src: imageAttrs?.src || null,
|
src: imageAttrs?.src || null,
|
||||||
alt: imageAttrs?.alt || "",
|
alt: imageAttrs?.alt || "",
|
||||||
caption: imageAttrs?.caption || "",
|
caption: imageAttrs?.caption || "",
|
||||||
@@ -126,6 +128,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignImageInline = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setImageAlign("inline")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
if (!editorState?.src) return;
|
if (!editorState?.src) return;
|
||||||
const url = getFileUrl(editorState.src);
|
const url = getFileUrl(editorState.src);
|
||||||
@@ -259,6 +269,18 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Inline (side by side)")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignImageInline}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Inline (side by side)")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isInline })}
|
||||||
|
>
|
||||||
|
<IconLayoutColumns size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<div className={classes.divider} />
|
<div className={classes.divider} />
|
||||||
|
|
||||||
{altTextButton}
|
{altTextButton}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Running a local dev stand
|
||||||
|
|
||||||
|
How to bring up a working local instance (API + client + realtime collaboration)
|
||||||
|
and the non-obvious gotchas that will otherwise eat an hour. Written from real
|
||||||
|
setup pain — read the **Gotchas** section before you start.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node 20+ / pnpm 10+.**
|
||||||
|
- **Postgres with pgvector.** Use the `pgvector/pgvector` image (e.g.
|
||||||
|
`pgvector/pgvector:pg18`). The stock `postgres` image will FAIL the
|
||||||
|
`CREATE EXTENSION vector` migration — the RAG feature stores embeddings in
|
||||||
|
`page_embeddings`.
|
||||||
|
- **Redis** — backs caching, BullMQ queues, the Socket.IO adapter, and collab
|
||||||
|
sync.
|
||||||
|
|
||||||
|
## 1. Environment (`.env`)
|
||||||
|
|
||||||
|
The client (`apps/client/vite.config.ts`) and both server processes read env via
|
||||||
|
`envPath` → the **workspace root `.env`**. Keep a single source of truth. Minimum:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
PORT=3000
|
||||||
|
APP_SECRET=<one long secret — SAME value everywhere, see gotcha #3>
|
||||||
|
DATABASE_URL="postgresql://<user>:<pass>@localhost:5432/<db>?schema=public"
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
COLLAB_URL=http://localhost:3001 # where the CLIENT connects for realtime
|
||||||
|
COLLAB_PORT=3001 # where the COLLAB server listens
|
||||||
|
STORAGE_DRIVER=local
|
||||||
|
DISABLE_TELEMETRY=true
|
||||||
|
```
|
||||||
|
|
||||||
|
> If you also keep an `apps/server/.env`, its `APP_SECRET` **must match** the
|
||||||
|
> root one (see gotcha #3).
|
||||||
|
|
||||||
|
## 2. Migrations
|
||||||
|
|
||||||
|
Migrations do **not** auto-run in local dev. After a fresh checkout or switching
|
||||||
|
branches, apply them yourself or endpoints touching a new column/table will 500:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter server migration:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Bring it up — THREE processes, not two
|
||||||
|
|
||||||
|
`pnpm dev` starts only the **API server** (Nest, `:3000`) and the **client**
|
||||||
|
(Vite). Realtime collaboration is a **separate process** and `pnpm dev` does NOT
|
||||||
|
start it. You need all three:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) API + client (from the repo root)
|
||||||
|
pnpm dev
|
||||||
|
# → API http://localhost:3000
|
||||||
|
# → client http://localhost:5173 (Vite; localhost-only by default)
|
||||||
|
|
||||||
|
# 2) Collaboration server — SEPARATE process. Build first (see gotcha #2), then:
|
||||||
|
pnpm --filter server build # produces dist/collaboration/server/collab-main.js
|
||||||
|
pnpm collab:dev # node dist/.../collab-main → listens on :3001 (0.0.0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Without step 2 the editor shows **"Real-time editor connection lost. Retrying…"**,
|
||||||
|
stays in read-only *static* mode, and anything that only mounts in the *live*
|
||||||
|
editor won't appear.
|
||||||
|
|
||||||
|
## Seeding a login
|
||||||
|
|
||||||
|
Register through the UI, or reset an existing user's password directly in the DB
|
||||||
|
(the server hashes with `bcrypt`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// node -e '...' with pg + bcrypt from the repo's node_modules
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const { Client } = require("pg");
|
||||||
|
(async () => {
|
||||||
|
const hash = await bcrypt.hash("demopass", 10);
|
||||||
|
const c = new Client({ /* DATABASE_URL parts */ });
|
||||||
|
await c.connect();
|
||||||
|
await c.query("update users set password=$1 where email=$2", [hash, "admin@example.com"]);
|
||||||
|
await c.end();
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Use a simple one-word password with no special characters** (e.g. `demopass`,
|
||||||
|
> not `Str0ng!Pass@2026`). Demo/test credentials get passed through shells, JSON
|
||||||
|
> payloads, and URLs by scripts and automation, where `!` `@` `$` `&` etc. get
|
||||||
|
> mangled or need escaping — a plain alphanumeric word avoids a whole class of
|
||||||
|
> "wrong password" confusion.
|
||||||
|
|
||||||
|
## Gotchas (the грабли)
|
||||||
|
|
||||||
|
1. **Collaboration is a third process.** `pnpm dev` runs API + client only.
|
||||||
|
Start `pnpm collab:dev` (on `:3001`) separately or the live editor never
|
||||||
|
connects. The client connects to `COLLAB_URL` directly (default
|
||||||
|
`http://localhost:3001`), NOT through the Vite `/collab` proxy — the API
|
||||||
|
server on `:3000` does **not** serve the collab websocket.
|
||||||
|
|
||||||
|
2. **The collab server must be built — you can't run it from source.**
|
||||||
|
`collab:dev` runs `node dist/collaboration/server/collab-main.js`, so run
|
||||||
|
`pnpm --filter server build` first. Running the entry via `tsx`/`ts-node`
|
||||||
|
fails with a NestJS DI error ("dependency … appears to be undefined at
|
||||||
|
runtime") because direct TS execution doesn't emit the decorator metadata the
|
||||||
|
built output has.
|
||||||
|
|
||||||
|
3. **`APP_SECRET` must be identical for the API server and the collab server.**
|
||||||
|
The API issues a collab-token (JWT signed with `APP_SECRET`); the collab
|
||||||
|
server validates it with `APP_SECRET`. If they load different values (e.g. a
|
||||||
|
root `.env` and an `apps/server/.env` with different secrets), every realtime
|
||||||
|
connection is rejected with **`[onAuthenticate] Invalid collab token`** and
|
||||||
|
the editor shows "connection lost". Keep one secret everywhere.
|
||||||
|
|
||||||
|
4. **Vite binds localhost only.** To reach the stand from another machine on the
|
||||||
|
LAN, start the client with `--host` (`pnpm --filter client exec vite --host`)
|
||||||
|
and use the box's LAN IP. The `/api`, `/socket.io`, and `/collab` Vite proxies
|
||||||
|
forward to `APP_URL`, so the API just works over the LAN; realtime needs
|
||||||
|
`COLLAB_URL` reachable from the browser (point it at the LAN IP:3001, and run
|
||||||
|
collab on `0.0.0.0` — it does by default).
|
||||||
|
|
||||||
|
5. **A stale `@docmost/editor-ext` white-screens the client.** The client imports
|
||||||
|
from `@docmost/editor-ext` (a workspace package). If that package's source is
|
||||||
|
behind (missing a newer export, e.g. `Spoiler`), the client dies at load with
|
||||||
|
*"The requested module … does not provide an export named 'Spoiler'"* → blank
|
||||||
|
page. Make sure the workspace `packages/editor-ext` is current for the branch
|
||||||
|
you're running (a stale sibling checkout resolved through a shared
|
||||||
|
`node_modules` symlink is the usual cause).
|
||||||
|
|
||||||
|
6. **pgvector, not stock postgres** (see Prerequisites) — the `vector` extension
|
||||||
|
migration fails otherwise.
|
||||||
|
|
||||||
|
7. **Migrations don't auto-run in dev** — run `migration:latest` after every pull
|
||||||
|
or branch switch.
|
||||||
|
|
||||||
|
See also the **Commands** and **Architecture → Two server processes** sections in
|
||||||
|
[`AGENTS.md`](../AGENTS.md).
|
||||||
@@ -63,6 +63,38 @@ describe("applyAlignment", () => {
|
|||||||
expect(el.dataset.imageAlign).toBe("center");
|
expect(el.dataset.imageAlign).toBe("center");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("inline -> inline-block + top alignment + gap padding, no float", () => {
|
||||||
|
applyAlignment(el, "inline");
|
||||||
|
expect(el.style.display).toBe("inline-block");
|
||||||
|
expect(el.style.verticalAlign).toBe("top");
|
||||||
|
expect(el.style.padding).toBe("0px 10px 10px 0px");
|
||||||
|
expect(el.dataset.imageAlign).toBe("inline");
|
||||||
|
expect(el.style.cssFloat).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears inline-block when switching inline -> center (reset-then-apply)", () => {
|
||||||
|
applyAlignment(el, "inline");
|
||||||
|
expect(el.style.display).toBe("inline-block");
|
||||||
|
// Switching back to a flex alignment must replace the inline-block
|
||||||
|
// override with the constructor-style flex, not just clear it.
|
||||||
|
applyAlignment(el, "center");
|
||||||
|
expect(el.style.display).toBe("flex");
|
||||||
|
expect(el.style.verticalAlign).toBe("");
|
||||||
|
expect(el.style.padding).toBe("");
|
||||||
|
expect(el.dataset.imageAlign).toBe("center");
|
||||||
|
expect(el.style.justifyContent).toBe("center");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a previous float when switching floatLeft -> inline", () => {
|
||||||
|
applyAlignment(el, "floatLeft");
|
||||||
|
expect(el.style.cssFloat).toBe("left");
|
||||||
|
applyAlignment(el, "inline");
|
||||||
|
expect(el.style.cssFloat).toBe("");
|
||||||
|
expect(el.style.display).toBe("inline-block");
|
||||||
|
expect(el.style.verticalAlign).toBe("top");
|
||||||
|
expect(el.dataset.imageAlign).toBe("inline");
|
||||||
|
});
|
||||||
|
|
||||||
it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
|
it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
|
||||||
applyAlignment(el, "floatLeft");
|
applyAlignment(el, "floatLeft");
|
||||||
expect(el.style.cssFloat).toBe("left");
|
expect(el.style.cssFloat).toBe("left");
|
||||||
|
|||||||
@@ -53,7 +53,13 @@ declare module "@tiptap/core" {
|
|||||||
attributes: ImageAttributes & { pos: number | Range },
|
attributes: ImageAttributes & { pos: number | Range },
|
||||||
) => ReturnType;
|
) => ReturnType;
|
||||||
setImageAlign: (
|
setImageAlign: (
|
||||||
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
|
align:
|
||||||
|
| "left"
|
||||||
|
| "center"
|
||||||
|
| "right"
|
||||||
|
| "floatLeft"
|
||||||
|
| "floatRight"
|
||||||
|
| "inline",
|
||||||
) => ReturnType;
|
) => ReturnType;
|
||||||
setImageWidth: (width: number) => ReturnType;
|
setImageWidth: (width: number) => ReturnType;
|
||||||
setImageSize: (width: number, height: number) => ReturnType;
|
setImageSize: (width: number, height: number) => ReturnType;
|
||||||
@@ -415,6 +421,14 @@ export function applyAlignment(container: HTMLElement, align: string) {
|
|||||||
// (a previous float must not leak into a later left/center/right).
|
// (a previous float must not leak into a later left/center/right).
|
||||||
container.style.cssFloat = "";
|
container.style.cssFloat = "";
|
||||||
container.style.padding = "";
|
container.style.padding = "";
|
||||||
|
// The ResizableNodeView constructor sets an inline `display: flex` on the
|
||||||
|
// container; the inline mode overrides it with `inline-block`, so the reset
|
||||||
|
// restores the constructor's flex here. This keeps the container's layout
|
||||||
|
// independent of any app-level CSS class (which also happens to set flex)
|
||||||
|
// and makes non-inline modes carry exactly the same inline styles as before
|
||||||
|
// the inline mode existed.
|
||||||
|
container.style.display = "flex";
|
||||||
|
container.style.verticalAlign = "";
|
||||||
// Mirror the resolved alignment onto the CONTAINER as a data attribute so the
|
// Mirror the resolved alignment onto the CONTAINER as a data attribute so the
|
||||||
// responsive stylesheet can neutralize the float on small screens (an inline
|
// responsive stylesheet can neutralize the float on small screens (an inline
|
||||||
// `float` can only be overridden by `!important`, which keys off this attr).
|
// `float` can only be overridden by `!important`, which keys off this attr).
|
||||||
@@ -430,6 +444,15 @@ export function applyAlignment(container: HTMLElement, align: string) {
|
|||||||
container.style.cssFloat = "right";
|
container.style.cssFloat = "right";
|
||||||
container.style.padding = "0 0 0 10px";
|
container.style.padding = "0 0 0 10px";
|
||||||
container.style.justifyContent = "flex-end";
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else if (align === "inline") {
|
||||||
|
// Consecutive inline images sit side by side on one line box and wrap to
|
||||||
|
// the next line when the viewport is narrow. The right/bottom padding
|
||||||
|
// provides the gap between images in a row and between wrapped rows;
|
||||||
|
// vertical-align: top keeps rows of different-height images aligned by
|
||||||
|
// their top edge.
|
||||||
|
container.style.display = "inline-block";
|
||||||
|
container.style.verticalAlign = "top";
|
||||||
|
container.style.padding = "0 10px 10px 0";
|
||||||
} else if (align === "left") {
|
} else if (align === "left") {
|
||||||
container.style.justifyContent = "flex-start";
|
container.style.justifyContent = "flex-start";
|
||||||
} else if (align === "right") {
|
} else if (align === "right") {
|
||||||
|
|||||||
Reference in New Issue
Block a user