From e423787c6fa5fb8222ce0fac59ab182dca655324 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 22 Aug 2026 17:55:53 +0900 Subject: [PATCH 1/2] feat: automate sponsor recognition --- .github/workflows/deploy-cloudflare.yml | 9 +- package.json | 3 +- scripts/build-output.test.ts | 29 +++ scripts/generate-sponsors.ts | 10 + scripts/github-sponsors.test.ts | 118 +++++++++++ scripts/github-sponsors.ts | 184 ++++++++++++++++++ scripts/site-contract.test.ts | 6 +- src/app/app.routes.spec.ts | 8 +- src/app/app.routes.ts | 7 +- src/app/docs/support-page.spec.ts | 45 +++++ src/app/docs/support-page.ts | 61 ++++++ .../capacitor-brotherprint.en.generated.ts | 2 +- src/app/generated/sponsors.generated.ts | 30 +++ src/locale/messages.en.xlf | 32 +++ src/locale/messages.ja.xlf | 4 + 15 files changed, 538 insertions(+), 10 deletions(-) create mode 100644 scripts/generate-sponsors.ts create mode 100644 scripts/github-sponsors.test.ts create mode 100644 scripts/github-sponsors.ts create mode 100644 src/app/docs/support-page.spec.ts create mode 100644 src/app/generated/sponsors.generated.ts diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml index 2bed689..dcfabd4 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare.yml @@ -6,6 +6,8 @@ on: types: [completed] branches: [main] workflow_dispatch: + schedule: + - cron: '17 3 * * *' permissions: contents: read @@ -21,7 +23,8 @@ jobs: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_sha == github.sha) || - (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + github.event_name == 'schedule' runs-on: ubuntu-latest timeout-minutes: 20 environment: @@ -43,7 +46,9 @@ jobs: run: npm ci - name: Build production assets - run: npm run build + run: npm run sponsors:generate && npm run build + env: + GITHUB_TOKEN: ${{ github.token }} - name: Deploy to Cloudflare run: npx wrangler deploy diff --git a/package.json b/package.json index e3e6df9..f2bc44c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "ng": "ng", "docs:generate": "tsx scripts/generate-docs.ts", + "sponsors:generate": "tsx scripts/generate-sponsors.ts", "prestart": "npm run docs:generate", "start": "ng serve", "prestart:ja": "npm run docs:generate", @@ -17,7 +18,7 @@ "build": "ng build && npm run static:prepare && pagefind --site dist/capacitor-plugins-docs/browser --output-subdir pagefind && tsx --test scripts/build-output.test.ts", "watch": "ng build --watch --configuration development", "pretest": "npm run docs:generate", - "test": "tsx --test scripts/docgen-readme.test.ts scripts/html-policy.test.ts scripts/markdown-headings.test.ts scripts/package-markdown.test.ts scripts/package-repository.test.ts scripts/bilingual-update-blocker.test.ts scripts/site-contract.test.ts && ng test --watch=false", + "test": "tsx --test scripts/docgen-readme.test.ts scripts/html-policy.test.ts scripts/markdown-headings.test.ts scripts/package-markdown.test.ts scripts/package-repository.test.ts scripts/github-sponsors.test.ts scripts/bilingual-update-blocker.test.ts scripts/site-contract.test.ts && ng test --watch=false", "fmt": "prettier --write .", "fmt:check": "prettier --check .", "deploy": "npm run build && wrangler deploy", diff --git a/scripts/build-output.test.ts b/scripts/build-output.test.ts index dd50522..4bb882b 100644 --- a/scripts/build-output.test.ts +++ b/scripts/build-output.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { access, constants, readdir, readFile, stat } from 'node:fs/promises'; import { join } from 'node:path'; import test from 'node:test'; +import { CURRENT_SPONSORS, PAST_SPONSORS } from '../src/app/generated/sponsors.generated'; test('places locale-specific static 404 pages in the browser output', async () => { const [english, japanese] = await Promise.all([ @@ -52,6 +53,34 @@ test('prerender output includes localized SEO metadata', async () => { assert.match(html, /name="twitter:card" content="summary_large_image"/); }); +test('prerenders current and past public sponsors in both locales', async () => { + const [english, japanese] = await Promise.all([ + readFile( + new URL('../dist/capacitor-plugins-docs/browser/support/index.html', import.meta.url), + 'utf8', + ), + readFile( + new URL('../dist/capacitor-plugins-docs/browser/ja/support/index.html', import.meta.url), + 'utf8', + ), + ]); + + for (const html of [english, japanese]) { + for (const sponsor of [...CURRENT_SPONSORS, ...PAST_SPONSORS]) { + assert.match(html, new RegExp(`href="${sponsor.profileUrl}"`)); + } + assert.doesNotMatch(html, /\$\d/); + } + if (CURRENT_SPONSORS.length > 0) { + assert.match(english, />Current sponsors現在のスポンサー 0) { + assert.match(english, />Past sponsors過去のスポンサー { const html = await readFile( new URL('../dist/capacitor-plugins-docs/browser/ja/index.html', import.meta.url), diff --git a/scripts/generate-sponsors.ts b/scripts/generate-sponsors.ts new file mode 100644 index 0000000..9df7b09 --- /dev/null +++ b/scripts/generate-sponsors.ts @@ -0,0 +1,10 @@ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fetchPublicSponsors, sponsorsModule } from './github-sponsors'; + +const outputPath = join(process.cwd(), 'src/app/generated/sponsors.generated.ts'); +const sponsors = await fetchPublicSponsors('rdlabo', process.env['GITHUB_TOKEN'] ?? ''); +await writeFile(outputPath, sponsorsModule(sponsors)); +console.log( + `Generated ${sponsors.current.length} current and ${sponsors.past.length} past public sponsors.`, +); diff --git a/scripts/github-sponsors.test.ts b/scripts/github-sponsors.test.ts new file mode 100644 index 0000000..6c0a12d --- /dev/null +++ b/scripts/github-sponsors.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { fetchPublicSponsors, sponsorsModule } from './github-sponsors'; + +function githubResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +test('fetches only valid public sponsors and sorts by tier without publishing amounts', async () => { + const requests: { activeOnly: boolean; cursor: string | null }[] = []; + const smallSponsor = { + sponsorEntity: { + login: 'small', + name: null, + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', + url: 'https://github.com/small', + }, + tier: { monthlyPriceInDollars: 5 }, + }; + const largeSponsor = { + sponsorEntity: { + login: 'large', + name: 'Large Sponsor', + avatarUrl: 'https://avatars.githubusercontent.com/u/2?v=4', + url: 'https://github.com/large', + }, + tier: { monthlyPriceInDollars: 100 }, + }; + const pastSponsor = { + sponsorEntity: { + login: 'one-time', + name: 'One-time Sponsor', + avatarUrl: 'https://avatars.githubusercontent.com/u/3?v=4', + url: 'https://github.com/one-time', + }, + tier: null, + }; + const fetchImplementation: typeof fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { + variables: { activeOnly: boolean; cursor: string | null }; + }; + const { activeOnly, cursor } = body.variables; + requests.push({ activeOnly, cursor }); + const nodes = activeOnly + ? cursor + ? [largeSponsor] + : [ + smallSponsor, + { + sponsorEntity: { + login: 'unsafe', + name: 'Unsafe image', + avatarUrl: 'https://example.com/avatar.png', + url: 'https://github.com/unsafe', + }, + tier: { monthlyPriceInDollars: 10_000 }, + }, + ] + : [smallSponsor, largeSponsor, largeSponsor, pastSponsor]; + return githubResponse({ + data: { + user: { + sponsorshipsAsMaintainer: { + nodes, + pageInfo: { + hasNextPage: activeOnly && cursor === null, + endCursor: activeOnly && cursor === null ? 'next' : null, + }, + }, + }, + }, + }); + }; + + const sponsors = await fetchPublicSponsors('rdlabo', 'test-token', fetchImplementation); + + assert.deepEqual(requests, [ + { activeOnly: true, cursor: null }, + { activeOnly: false, cursor: null }, + { activeOnly: true, cursor: 'next' }, + ]); + assert.deepEqual(sponsors, { + current: [ + { + login: 'large', + name: 'Large Sponsor', + avatarUrl: 'https://avatars.githubusercontent.com/u/2?v=4', + profileUrl: 'https://github.com/large', + }, + { + login: 'small', + name: 'small', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', + profileUrl: 'https://github.com/small', + }, + ], + past: [ + { + login: 'one-time', + name: 'One-time Sponsor', + avatarUrl: 'https://avatars.githubusercontent.com/u/3?v=4', + profileUrl: 'https://github.com/one-time', + }, + ], + }); + assert.doesNotMatch(sponsorsModule(sponsors), /monthlyPriceInDollars/); +}); + +test('fails closed when GitHub rejects the request', async () => { + const fetchImplementation: typeof fetch = async () => new Response('', { status: 401 }); + await assert.rejects( + () => fetchPublicSponsors('rdlabo', 'bad-token', fetchImplementation), + /HTTP 401/, + ); +}); diff --git a/scripts/github-sponsors.ts b/scripts/github-sponsors.ts new file mode 100644 index 0000000..ec504a2 --- /dev/null +++ b/scripts/github-sponsors.ts @@ -0,0 +1,184 @@ +export interface PublicSponsor { + login: string; + name: string; + avatarUrl: string; + profileUrl: string; +} + +export interface PublicSponsors { + current: PublicSponsor[]; + past: PublicSponsor[]; +} + +interface SponsorWithTier extends PublicSponsor { + monthlyPriceInDollars: number; +} + +interface SponsorshipNode { + sponsorEntity?: { + login?: unknown; + name?: unknown; + avatarUrl?: unknown; + url?: unknown; + } | null; + tier?: { monthlyPriceInDollars?: unknown } | null; +} + +interface SponsorsResponse { + data?: { + user?: { + sponsorshipsAsMaintainer?: { + nodes?: SponsorshipNode[]; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + }; + } | null; + }; + errors?: { message?: string }[]; +} + +const query = ` + query PublicSponsors($login: String!, $cursor: String, $activeOnly: Boolean!) { + user(login: $login) { + sponsorshipsAsMaintainer( + first: 100 + after: $cursor + activeOnly: $activeOnly + includePrivate: false + ) { + nodes { + sponsorEntity { + ... on User { login name avatarUrl url } + ... on Organization { login name avatarUrl url } + } + tier { monthlyPriceInDollars } + } + pageInfo { hasNextPage endCursor } + } + } + } +`; + +function isExpectedUrl(value: unknown, hostname: string): value is string { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && url.hostname === hostname; + } catch { + return false; + } +} + +function parseSponsor(node: SponsorshipNode): SponsorWithTier | undefined { + const entity = node.sponsorEntity; + const monthlyPrice = node.tier?.monthlyPriceInDollars; + if ( + typeof entity?.login !== 'string' || + entity.login.length === 0 || + !isExpectedUrl(entity.avatarUrl, 'avatars.githubusercontent.com') || + !isExpectedUrl(entity.url, 'github.com') || + (monthlyPrice !== null && + monthlyPrice !== undefined && + (typeof monthlyPrice !== 'number' || !Number.isFinite(monthlyPrice))) + ) { + return undefined; + } + return { + login: entity.login, + name: + typeof entity.name === 'string' && entity.name.trim().length > 0 + ? entity.name.trim() + : entity.login, + avatarUrl: entity.avatarUrl, + profileUrl: entity.url, + monthlyPriceInDollars: typeof monthlyPrice === 'number' ? monthlyPrice : 0, + }; +} + +async function fetchSponsorships( + login: string, + token: string, + activeOnly: boolean, + fetchImplementation: typeof fetch = fetch, +): Promise> { + const sponsors = new Map(); + let cursor: string | null = null; + do { + const response = await fetchImplementation('https://api.github.com/graphql', { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'rdlabo-docs-sponsor-generator', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({ query, variables: { login, cursor, activeOnly } }), + }); + if (!response.ok) { + throw new Error(`GitHub Sponsors request failed with HTTP ${response.status}`); + } + + const payload = (await response.json()) as SponsorsResponse; + if (payload.errors?.length) { + throw new Error( + `GitHub Sponsors request failed: ${payload.errors.map(({ message }) => message).join('; ')}`, + ); + } + const connection = payload.data?.user?.sponsorshipsAsMaintainer; + if (!connection) throw new Error(`GitHub user ${login} was not found`); + + for (const node of connection.nodes ?? []) { + const sponsor = parseSponsor(node); + if (sponsor) { + const key = sponsor.login.toLowerCase(); + const previous = sponsors.get(key); + if (!previous || sponsor.monthlyPriceInDollars > previous.monthlyPriceInDollars) { + sponsors.set(key, sponsor); + } + } + } + cursor = connection.pageInfo?.hasNextPage ? (connection.pageInfo.endCursor ?? null) : null; + if (connection.pageInfo?.hasNextPage && !cursor) { + throw new Error('GitHub Sponsors pagination did not return an end cursor'); + } + } while (cursor); + + return sponsors; +} + +function sortedSponsors(sponsors: SponsorWithTier[]): PublicSponsor[] { + return sponsors + .sort((left, right) => { + const tierOrder = right.monthlyPriceInDollars - left.monthlyPriceInDollars; + if (tierOrder !== 0) return tierOrder; + const leftLogin = left.login.toLowerCase(); + const rightLogin = right.login.toLowerCase(); + return leftLogin === rightLogin ? 0 : leftLogin < rightLogin ? -1 : 1; + }) + .map(({ monthlyPriceInDollars: _, ...sponsor }) => sponsor); +} + +export async function fetchPublicSponsors( + login: string, + token: string, + fetchImplementation: typeof fetch = fetch, +): Promise { + if (!token) throw new Error('GITHUB_TOKEN is required to update sponsors'); + + const [currentByLogin, allByLogin] = await Promise.all([ + fetchSponsorships(login, token, true, fetchImplementation), + fetchSponsorships(login, token, false, fetchImplementation), + ]); + return { + current: sortedSponsors([...currentByLogin.values()]), + past: sortedSponsors( + [...allByLogin.entries()] + .filter(([loginKey]) => !currentByLogin.has(loginKey)) + .map(([, sponsor]) => sponsor), + ), + }; +} + +export function sponsorsModule(sponsors: PublicSponsors): string { + return `// Generated by scripts/generate-sponsors.ts. Do not edit.\nexport const CURRENT_SPONSORS = ${JSON.stringify(sponsors.current, null, 2)} as const;\n\nexport const PAST_SPONSORS = ${JSON.stringify(sponsors.past, null, 2)} as const;\n`; +} diff --git a/scripts/site-contract.test.ts b/scripts/site-contract.test.ts index 4f38710..c1eff0c 100644 --- a/scripts/site-contract.test.ts +++ b/scripts/site-contract.test.ts @@ -929,15 +929,18 @@ test('deploys verified main revisions to Cloudflare', async () => { assert.match(workflow, /^name: Deploy to Cloudflare$/m); assert.match(workflow, /^ {2}workflow_run:$/m); + assert.match(workflow, /^ {2}schedule:$/m); + assert.match(workflow, /^ {4}- cron: '17 3 \* \* \*'$/m); assert.match(workflow, /^ {4}workflows: \[CI\]$/m); assert.match(workflow, /^ {4}branches: \[main\]$/m); assert.match(workflow, /github\.event\.workflow_run\.conclusion == 'success'/); assert.match(workflow, /github\.event\.workflow_run\.event == 'push'/); assert.match(workflow, /github\.event\.workflow_run\.head_sha == github\.sha/); assert.match(workflow, /github\.ref == 'refs\/heads\/main'/); + assert.match(workflow, /github\.event_name == 'schedule'/); assert.match(workflow, /ref: \$\{\{ github\.event\.workflow_run\.head_sha \|\| github\.sha \}\}/); assert.match(workflow, /^ {8}run: npm ci$/m); - assert.match(workflow, /^ {8}run: npm run build$/m); + assert.match(workflow, /^ {8}run: npm run sponsors:generate && npm run build$/m); assert.match(workflow, /^ {8}run: npx wrangler deploy$/m); const actionReferences = [...workflow.matchAll(/^\s+uses:\s+([^\s#]+)/gm)].map( (match) => match[1], @@ -950,6 +953,7 @@ test('deploys verified main revisions to Cloudflare', async () => { assert.match(reference, /^[\w.-]+\/[\w.-]+@[a-f0-9]{40}$/); } assert.match(workflow, /^ {10}CLOUDFLARE_API_TOKEN: \$\{\{ secrets\.CLOUDFLARE_API_TOKEN \}\}$/m); + assert.match(workflow, /^ {10}GITHUB_TOKEN: \$\{\{ github\.token \}\}$/m); assert.doesNotMatch(workflow, /netlify/i); }); diff --git a/src/app/app.routes.spec.ts b/src/app/app.routes.spec.ts index 0fb6bf8..af5ea72 100644 --- a/src/app/app.routes.spec.ts +++ b/src/app/app.routes.spec.ts @@ -4,7 +4,6 @@ import { routes } from './app.routes'; import { projectCatalog } from './docs/docs-data'; import { NotFoundComponent } from './docs/not-found'; import { PluginIndexComponent } from './docs/plugin-index'; -import { SupportPageComponent } from './docs/support-page'; describe('routes', () => { it('exposes one canonical project index and redirects its alias', () => { @@ -12,8 +11,11 @@ describe('routes', () => { expect(routes.find((route) => route.path === 'projects')?.redirectTo).toBe(''); }); - it('exposes the shared support page outside individual project documentation', () => { - expect(routes.find((route) => route.path === 'support')?.component).toBe(SupportPageComponent); + it('lazy-loads the shared support page outside individual project documentation', () => { + const supportRoute = routes.find((route) => route.path === 'support'); + expect(supportRoute).toBeDefined(); + expect(supportRoute!.component).toBeUndefined(); + expect(supportRoute!.loadComponent).toBeTypeOf('function'); }); it('uses canonical project routes and redirects every former AdMob route', () => { diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index d0ba513..05e3dd1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -5,7 +5,6 @@ import { NotFoundComponent } from './docs/not-found'; import { PluginIndexComponent } from './docs/plugin-index'; import { projectCatalog } from './docs/docs-data'; import { projectResolver } from './docs/project.resolver'; -import { SupportPageComponent } from './docs/support-page'; const portalProjects = projectCatalog.filter((project) => !project.hostedUrl); @@ -60,7 +59,11 @@ const admobLegacyDocRedirects: Routes = [ export const routes: Routes = [ { path: '', pathMatch: 'full', component: PluginIndexComponent }, { path: 'projects', pathMatch: 'full', redirectTo: '' }, - { path: 'support', component: SupportPageComponent }, + { + path: 'support', + loadComponent: () => + import('./docs/support-page').then(({ SupportPageComponent }) => SupportPageComponent), + }, { path: 'docs/identity', pathMatch: 'full', diff --git a/src/app/docs/support-page.spec.ts b/src/app/docs/support-page.spec.ts new file mode 100644 index 0000000..e768be0 --- /dev/null +++ b/src/app/docs/support-page.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { CURRENT_SPONSORS, PAST_SPONSORS } from '../generated/sponsors.generated'; +import { SupportPageComponent } from './support-page'; + +describe('SupportPageComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SupportPageComponent], + providers: [provideRouter([])], + }).compileComponents(); + fixture = TestBed.createComponent(SupportPageComponent); + fixture.detectChanges(); + }); + + it('renders every generated public sponsor without exposing sponsorship amounts', () => { + const compiled = fixture.nativeElement as HTMLElement; + const sponsorLinks = Array.from( + compiled.querySelectorAll('[aria-labelledby="current-sponsors-heading"] li a'), + ); + + expect(sponsorLinks).toHaveLength(CURRENT_SPONSORS.length); + expect(sponsorLinks.map(({ href }) => href)).toEqual( + CURRENT_SPONSORS.map(({ profileUrl }) => profileUrl), + ); + for (const sponsor of CURRENT_SPONSORS) { + expect(compiled.textContent).toContain(sponsor.name); + expect(compiled.textContent).toContain(`@${sponsor.login}`); + } + const pastSponsorLinks = Array.from( + compiled.querySelectorAll('[aria-labelledby="past-sponsors-heading"] li a'), + ); + expect(pastSponsorLinks).toHaveLength(PAST_SPONSORS.length); + expect(pastSponsorLinks.map(({ href }) => href)).toEqual( + PAST_SPONSORS.map(({ profileUrl }) => profileUrl), + ); + for (const sponsor of PAST_SPONSORS) { + expect(compiled.textContent).toContain(sponsor.name); + expect(compiled.textContent).toContain(`@${sponsor.login}`); + } + expect(compiled.textContent).not.toMatch(/\$\d/); + }); +}); diff --git a/src/app/docs/support-page.ts b/src/app/docs/support-page.ts index 3f07fc0..84c6aeb 100644 --- a/src/app/docs/support-page.ts +++ b/src/app/docs/support-page.ts @@ -1,4 +1,5 @@ import { Component, OnInit, inject } from '@angular/core'; +import { CURRENT_SPONSORS, PAST_SPONSORS } from '../generated/sponsors.generated'; import { SeoService } from './seo.service'; @Component({ @@ -20,6 +21,62 @@ import { SeoService } from './seo.service'; >

+ @for (group of sponsorGroups; track group.id) { + @if (group.sponsors.length > 0) { +
+

+ @if (group.id === 'current') { + Current sponsors + } @else { + Past sponsors + } +

+

+ @if (group.id === 'current') { + Thank you to the people and organizations supporting rdlabo's open source + work. + } @else { + Thank you also to everyone who has supported this work in the past. + } +

+ +
+ } + } +
@@ -57,6 +114,10 @@ import { SeoService } from './seo.service'; }) export class SupportPageComponent implements OnInit { readonly #seo = inject(SeoService); + readonly sponsorGroups = [ + { id: 'current', sponsors: CURRENT_SPONSORS }, + { id: 'past', sponsors: PAST_SPONSORS }, + ] as const; ngOnInit(): void { this.#seo.setPage({ diff --git a/src/app/generated/projects/capacitor-brotherprint.en.generated.ts b/src/app/generated/projects/capacitor-brotherprint.en.generated.ts index 7964bc9..a1ccffd 100644 --- a/src/app/generated/projects/capacitor-brotherprint.en.generated.ts +++ b/src/app/generated/projects/capacitor-brotherprint.en.generated.ts @@ -36,7 +36,7 @@ export const PROJECT = { "file": "readme.md", "section": "Quickstart", "path": "/projects/capacitor-brotherprint/docs/readme", - "html": "

Capacitor Brother Print is a native Brother Print SDK implementation for iOS & Android. Support These models.

\n

This plugin is still in the RC (release candidate) phase.

\n

Brother Print SDK is incompatible with CocoaPods and Minimum Developments iOS 14 and is not working at this time, please use Swift Package Manager.

\n

Supported models

\n

Each product link is an Amazon affiliate link. If you choose to make a purchase through these links, it would be greatly appreciated and would help support development costs. Thank you!

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ProductModeliOS/WiFiiOS/BTiOS/BLEAndroid/USBAndroid/WiFiAndroid/BTAndroid/BLE
QL-810WQL_810W
QL-820NWBQL_820NWB※1
QL-820NWBcQL_820NWB※2
TD-2320DTD_2320D_203
TD-2350DTD_2350D_300
\n

Amazon Affiliate Links: https://amzn.to/3AiiOFT

\n

Supplement

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
description
Supported and tested
Implemented but not tested
-Plugin is not supported
Device is not supported
BTBluetooth
BLEBluetooth Low Energy
\n

※1 Due to low Bluetooth version, connection is not possible with iOS. Ref: https://okbizcs.okwave.jp/brother/qa/q9932082.html

\n

※2 The iOS/BT implementation for the QL-820NWBc is in place, but it’s uncertain if it functions correctly. It’s unclear whether this is an implementation issue, as Brother’s official app also doesn’t work well.

\n

Install

\n
% npm install @rdlabo/capacitor-brotherprint\n

For detailed SDK setup and permission configuration, see Installation.

\n

Usage

\n

See Search, Print, and Events.

\n", + "html": "

Capacitor Brother Print is a native Brother Print SDK implementation for iOS & Android. Support These models.

\n

This plugin is still in the RC (release candidate) phase.

\n

Brother Print SDK is incompatible with CocoaPods and Minimum Developments iOS 14 and is not working at this time, please use Swift Package Manager.

\n

Supported models

\n

Each product link is an Amazon affiliate link. If you choose to make a purchase through these links, it would be greatly appreciated and would help support development costs. Thank you!

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ProductModeliOS/WiFiiOS/BTiOS/BLEAndroid/USBAndroid/WiFiAndroid/BTAndroid/BLE
QL-810WQL_810W
QL-820NWBQL_820NWB※1
QL-820NWBcQL_820NWB※2
TD-2320DTD_2320D_203
TD-2350DTD_2350D_300
\n

Amazon Affiliate Links: https://amzn.to/3AiiOFT

\n

Supplement

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
description
Supported and tested
Implemented but not tested
-Plugin is not supported
Device is not supported
BTBluetooth
BLEBluetooth Low Energy
\n

※1 Due to low Bluetooth version, connection is not possible with iOS. Ref: https://okbizcs.okwave.jp/brother/qa/q9932082.html

\n

※2 The iOS/BT implementation for the QL-820NWBc is in place, but it’s uncertain if it functions correctly. It’s unclear whether this is an implementation issue, as Brother’s official app also doesn’t work well.

\n

Install

\n
% npm install @rdlabo/capacitor-brotherprint\n

For detailed SDK setup and permission configuration, see Installation.

\n

Usage

\n

See Search, Print, and Events.

\n", "headings": [ { "id": "supported-models", diff --git a/src/app/generated/sponsors.generated.ts b/src/app/generated/sponsors.generated.ts new file mode 100644 index 0000000..2a61cb3 --- /dev/null +++ b/src/app/generated/sponsors.generated.ts @@ -0,0 +1,30 @@ +// Generated by scripts/generate-sponsors.ts. Do not edit. +export const CURRENT_SPONSORS = [ + { + "login": "stripe", + "name": "Stripe", + "avatarUrl": "https://avatars.githubusercontent.com/u/856813?v=4", + "profileUrl": "https://github.com/stripe" + }, + { + "login": "langy", + "name": "Adam Lang", + "avatarUrl": "https://avatars.githubusercontent.com/u/713141?u=a3ffa165af3d80b19f68fc99ae5514f26c03af07&v=4", + "profileUrl": "https://github.com/langy" + }, + { + "login": "synapsmedia", + "name": "Synaps Media", + "avatarUrl": "https://avatars.githubusercontent.com/u/13181917?v=4", + "profileUrl": "https://github.com/synapsmedia" + } +] as const; + +export const PAST_SPONSORS = [ + { + login: 'featers', + name: 'BackResto', + avatarUrl: 'https://avatars.githubusercontent.com/u/98342106?v=4', + profileUrl: 'https://github.com/featers', + }, +] as const; diff --git a/src/locale/messages.en.xlf b/src/locale/messages.en.xlf index fa978d1..e66de77 100644 --- a/src/locale/messages.en.xlf +++ b/src/locale/messages.en.xlf @@ -282,6 +282,38 @@ The projects documented here are maintained personally by rdlabo. Sponsorship supports the collection as a whole, rather than one individual library. + + + src/app/docs/support-page.ts:30,32 + + + Current sponsors + + + + + src/app/docs/support-page.ts:34,36 + + + Thank you to the people and organizations supporting rdlabo's open source work. + + + + + src/app/docs/support-page.ts + + + Past sponsors + + + + + src/app/docs/support-page.ts + + + Thank you also to everyone who has supported this work in the past. + + src/app/docs/support-page.ts:28,30 diff --git a/src/locale/messages.ja.xlf b/src/locale/messages.ja.xlf index abb221e..e8e964d 100644 --- a/src/locale/messages.ja.xlf +++ b/src/locale/messages.ja.xlf @@ -89,6 +89,10 @@ Support open sourceオープンソースを支援する Help rdlabo projects keep movingrdlaboのプロジェクトを、これからも前へ The projects documented here are maintained personally by rdlabo. Sponsorship supports the collection as a whole, rather than one individual library.ここに掲載するプロジェクトは、rdlabo個人が開発・管理しています。スポンサーからのご支援は、特定のライブラリではなく、プロジェクト全体の活動を支えます。 + Current sponsors現在のスポンサー + Thank you to the people and organizations supporting rdlabo's open source work.rdlaboのオープンソース活動を支えてくださる皆さまに感謝します。 + Past sponsors過去のスポンサー + Thank you also to everyone who has supported this work in the past.これまで活動を支えてくださった皆さまにも感謝します。 Sponsor on GitHubGitHub Sponsorsで支援する Your support helps fund maintenance, compatibility updates, documentation, and new features across rdlabo's open source projects.いただいたご支援は、rdlaboのオープンソースプロジェクト全体のメンテナンス、互換性対応、ドキュメント整備、新機能開発に活用します。 Become a sponsorスポンサーになる From a264305b38fe2c106ccd220254efab2d01b5918e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 22 Aug 2026 18:05:57 +0900 Subject: [PATCH 2/2] docs: sync photo editor header schemes --- ...ionic-angular-photo-editor.en.generated.ts | 23 +++++++++++++++---- ...ionic-angular-photo-editor.ja.generated.ts | 21 ++++++++++++++--- .../docs/ja/editor.md | 22 +++++++++++------- .../docs/ja/theme.md | 18 +++++++++++++++ .../docs/ja/viewer.md | 18 ++++++++++----- 5 files changed, 81 insertions(+), 21 deletions(-) diff --git a/src/app/generated/projects/ionic-angular-photo-editor.en.generated.ts b/src/app/generated/projects/ionic-angular-photo-editor.en.generated.ts index 75629a1..2c747e5 100644 --- a/src/app/generated/projects/ionic-angular-photo-editor.en.generated.ts +++ b/src/app/generated/projects/ionic-angular-photo-editor.en.generated.ts @@ -36,7 +36,7 @@ export const PROJECT = { "file": "readme.md", "section": "Quickstart", "path": "/projects/ionic-angular-photo-editor/docs/readme", - "html": "

Overview

\n

This is a photo editor and viewer for modal page of Ionic Angular project using Capacitor.

\n

Features

\n

Choose by editing goal

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
GoalGuide
Load a photo from camera or albumPhotoFileService
Crop and edit in a modalPhoto Editor
Browse images in a modalPhoto Viewer
Override editor colorsTheme
\n

Quick start

\n

After Installation, load a photo:

\n
import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor';\n\nconst files = await this.photoFileService.loadPhoto(1);\n

Then present the editor or viewer. Details: PhotoFileService, Photo Editor, Photo Viewer.

\n

Installation

\n
npm install @rdlabo/ionic-angular-photo-editor\n

If you use capacitor, you need to install plugin:

\n
npm install @capacitor/camera swiper tui-image-editor\n

And set permission. more info is here: Camera

\n

If you public your project to the web, you need to add the following input tag to the index.html.

\n
<div style=\"width: 0; height: 0; overflow: hidden\">\n  <input id=\"browserPhotoUploader\" type=\"file\" accept=\"image/*\" />\n</div>\n

Documentation

\n

Start with Installation, then pick a guide.

\n\n", + "html": "

Overview

\n

This is a photo editor and viewer for modal page of Ionic Angular project using Capacitor.

\n

Features

\n

Choose by editing goal

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
GoalGuide
Load a photo from camera or albumPhotoFileService
Crop and edit in a modalPhoto Editor
Browse images in a modalPhoto Viewer
Override editor colorsTheme
Upgrade from an earlier releaseMigration guide
\n

Quick start

\n

After Installation, load a photo:

\n
import { PhotoFileService } from '@rdlabo/ionic-angular-photo-editor';\n\nconst files = await this.photoFileService.loadPhoto(1);\n

Then present the editor or viewer. Details: PhotoFileService, Photo Editor, Photo Viewer.

\n

Installation

\n
npm install @rdlabo/ionic-angular-photo-editor\n

If you use capacitor, you need to install plugin:

\n
npm install @capacitor/camera swiper tui-image-editor\n

And set permission. more info is here: Camera

\n

If you public your project to the web, you need to add the following input tag to the index.html.

\n
<div style=\"width: 0; height: 0; overflow: hidden\">\n  <input id=\"browserPhotoUploader\" type=\"file\" accept=\"image/*\" />\n</div>\n

Documentation

\n

Start with Installation, then pick a guide.

\n\n", "headings": [ { "id": "overview", @@ -80,12 +80,17 @@ export const PROJECT = { "file": "theme.md", "section": "Guides", "path": "/projects/ionic-angular-photo-editor/docs/theme", - "html": "

Override the editor colors after Installation.

\n

Default color is set, but user can overwrite it: https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/pages/core.scss

\n

How to overwrite

\n
:root {\n  --ion-photo-editor-background: #2a2a2a;\n  --ion-photo-editor-background-tint: #414141;\n\n  --ion-photo-editor-color: #f0f0f0;\n  --ion-photo-editor-color-tint: #dbdbdb;\n\n  --ion-photo-editor-primary: #4d8dff;\n  --ion-photo-editor-danger: #f24c58;\n  --ion-photo-editor-success: #2dd55b;\n}\n
", + "html": "

Override the editor colors after Installation.

\n

Default color is set, but user can overwrite it: https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/pages/core.scss

\n

How to overwrite

\n
:root {\n  --ion-photo-editor-background: #2a2a2a;\n  --ion-photo-editor-background-tint: #414141;\n\n  --ion-photo-editor-color: #f0f0f0;\n  --ion-photo-editor-color-tint: #dbdbdb;\n\n  --ion-photo-editor-primary: #4d8dff;\n  --ion-photo-editor-danger: #f24c58;\n  --ion-photo-editor-success: #2dd55b;\n\n  --ion-photo-editor-header-button-color-on-light: #222428;\n  --ion-photo-editor-header-button-color-on-dark: #f4f5f8;\n}\n

Header button color scheme

\n

PhotoEditorPage and PhotoViewerPage require headerButtonColorScheme: 'light' | 'dark' in their modal componentProps. Select dark for a dark/black ion-toolbar and light for a light/white toolbar. The consumer must choose because the library cannot reliably infer the final toolbar appearance from CSS, translucency, or runtime theme overrides.

\n

For @rdlabo/ionic-theme-ios26 v3, import the optional integration stylesheet after the iOS 26 theme and dark-mode styles:

\n
@import '@rdlabo/ionic-theme-ios26/dist/css/ionic-theme-ios26.css';\n@import '@ionic/angular/css/palettes/dark.class.css';\n@import '@rdlabo/ionic-theme-ios26/dist/css/ionic-theme-ios26-dark-class.css';\n@import '@rdlabo/ionic-angular-photo-editor/css/ios26-header-button-color-scheme.css';\n

Use the matching Always or System dark-mode import instead when appropriate. The photo-editor integration stylesheet must remain last so its local header scheme can override the ambient application scheme. Applications that do not use the iOS 26 theme should not import this optional stylesheet; they receive only the regular Ionic button foreground-color switch.

\n", "headings": [ { "id": "how-to-overwrite", "text": "How to overwrite", "level": 2 + }, + { + "id": "header-button-color-scheme", + "text": "Header button color scheme", + "level": 2 } ], "codes": [], @@ -118,7 +123,7 @@ export const PROJECT = { "file": "editor.md", "section": "Guides", "path": "/projects/ionic-angular-photo-editor/docs/editor", - "html": "

Present PhotoEditorPage in an Ionic modal. Call this after Installation.

\n
import { PhotoEditorPage, IPhotoEditorDismiss } from '@rdlabo/ionic-angular-photo-editor';\n\n(async () => {\n  const modal = await this.modalCtrl.create({\n    component: PhotoEditorPage,\n    componentProps: {\n      requireSquare: false,\n      value: 'https://picsum.photos/200/300',\n      labels: {\n        save: '送信', // change '保存' to '送信'\n      },\n    },\n  });\n  await modal.present();\n  const { data } = await modal.onWillDismiss<IPhotoEditorDismiss>();\n  if (data?.value) {\n    console.log(data.value);\n  }\n})();\n

Options

\n

requireSquare: boolean

\n

If true, the image must be cropped to a square at first.

\n

value: string

\n

The image url or base64 string.

\n

labels: IDictionaryForEditor

\n

If set, the label is overwritten.

\n

List is here.

\n", + "html": "

Present PhotoEditorPage in an Ionic modal. Call this after Installation.

\n
import { PhotoEditorPage, IPhotoEditorDismiss, PhotoEditorProps } from '@rdlabo/ionic-angular-photo-editor';\n\n(async () => {\n  const componentProps = {\n    requireSquare: false,\n    value: 'https://picsum.photos/200/300',\n    headerButtonColorScheme: 'dark',\n    labels: {\n      save: '送信', // change '保存' to '送信'\n    },\n  } satisfies PhotoEditorProps;\n  const modal = await this.modalCtrl.create({\n    component: PhotoEditorPage,\n    componentProps,\n  });\n  await modal.present();\n  const { data } = await modal.onWillDismiss<IPhotoEditorDismiss>();\n  if (data?.value) {\n    console.log(data.value);\n  }\n})();\n

Options

\n

requireSquare: boolean

\n

If true, the image must be cropped to a square at first.

\n

value: string

\n

The image url or base64 string.

\n

labels: IDictionaryForEditor

\n

If set, the label is overwritten.

\n

List is here.

\n

headerButtonColorScheme: 'light' | 'dark'

\n

Required. Select dark for a dark/black ion-toolbar and light for a light/white toolbar. The library cannot infer the toolbar appearance from CSS, translucent content, or runtime theme overrides.

\n", "headings": [ { "id": "options", @@ -139,6 +144,11 @@ export const PROJECT = { "id": "labels%3A-idictionaryforeditor", "text": "labels: IDictionaryForEditor", "level": 4 + }, + { + "id": "headerbuttoncolorscheme%3A-'light'-%7C-'dark'", + "text": "headerButtonColorScheme: 'light' | 'dark'", + "level": 4 } ], "codes": [], @@ -152,7 +162,7 @@ export const PROJECT = { "file": "viewer.md", "section": "Guides", "path": "/projects/ionic-angular-photo-editor/docs/viewer", - "html": "

Present PhotoViewerPage in an Ionic modal. Call this after Installation.

\n
import { PhotoViewerPage, IPhotoViewerDismiss } from '@rdlabo/ionic-angular-photo-editor';\n\n(async () => {\n  const modal = await this.modalCtrl.create({\n    component: PhotoViewerPage,\n    componentProps: {\n      imageUrls: ['https://picsum.photos/200/300', 'https://picsum.photos/200/300'],\n      index: 0,\n      isCircle: false,\n    },\n  });\n  await modal.present();\n  const { data } = await modal.onWillDismiss<IPhotoViewerDismiss>();\n  if (data?.delete) {\n    // User delete image\n  }\n})();\n

Options

\n

imageUrls: string[]

\n

The image url or base64 string[].

\n

index: number

\n

The index of imageUrls.

\n

isCircle: boolean

\n

If set, the image is displayed in a circle.

\n

enableDelete: boolean

\n

If true, the delete button is displayed.

\n

enableFooterSafeArea: boolean

\n

If true, enable footer safe area for iOS.

\n

labels: IDictionaryForViewer

\n

If set, the label is overwritten.

\n

List is here.

\n", + "html": "

Present PhotoViewerPage in an Ionic modal. Call this after Installation.

\n
import { PhotoViewerPage, IPhotoViewerDismiss, PhotoViewerProps } from '@rdlabo/ionic-angular-photo-editor';\n\n(async () => {\n  const componentProps = {\n    imageUrls: ['https://picsum.photos/200/300', 'https://picsum.photos/200/300'],\n    index: 0,\n    isCircle: false,\n    headerButtonColorScheme: 'dark',\n  } satisfies PhotoViewerProps;\n  const modal = await this.modalCtrl.create({\n    component: PhotoViewerPage,\n    componentProps,\n  });\n  await modal.present();\n  const { data } = await modal.onWillDismiss<IPhotoViewerDismiss>();\n  if (data?.delete) {\n    // User delete image\n  }\n})();\n

Options

\n

imageUrls: string[]

\n

The image url or base64 string[].

\n

index: number

\n

The index of imageUrls.

\n

isCircle: boolean

\n

If set, the image is displayed in a circle.

\n

enableDelete: boolean

\n

If true, the delete button is displayed.

\n

enableFooterSafeArea: boolean

\n

If true, enable footer safe area for iOS.

\n

labels: IDictionaryForViewer

\n

If set, the label is overwritten.

\n

List is here.

\n

headerButtonColorScheme: 'light' | 'dark'

\n

Required. Select dark for a dark/black ion-toolbar and light for a light/white toolbar. The library cannot infer the toolbar appearance from CSS, translucent content, or runtime theme overrides.

\n", "headings": [ { "id": "options", @@ -188,6 +198,11 @@ export const PROJECT = { "id": "labels%3A-idictionaryforviewer", "text": "labels: IDictionaryForViewer", "level": 4 + }, + { + "id": "headerbuttoncolorscheme%3A-'light'-%7C-'dark'", + "text": "headerButtonColorScheme: 'light' | 'dark'", + "level": 4 } ], "codes": [], diff --git a/src/app/generated/projects/ionic-angular-photo-editor.ja.generated.ts b/src/app/generated/projects/ionic-angular-photo-editor.ja.generated.ts index aaed7c1..864f490 100644 --- a/src/app/generated/projects/ionic-angular-photo-editor.ja.generated.ts +++ b/src/app/generated/projects/ionic-angular-photo-editor.ja.generated.ts @@ -80,12 +80,17 @@ export const PROJECT = { "file": "theme.md", "section": "ガイド", "path": "/projects/ionic-angular-photo-editor/docs/theme", - "html": "

インストール のあと、エディターの色を上書きします。

\n

デフォルトの色は設定済みですが、上書きできます: https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/pages/core.scss

\n

上書き方法

\n
:root {\n  --ion-photo-editor-background: #2a2a2a;\n  --ion-photo-editor-background-tint: #414141;\n\n  --ion-photo-editor-color: #f0f0f0;\n  --ion-photo-editor-color-tint: #dbdbdb;\n\n  --ion-photo-editor-primary: #4d8dff;\n  --ion-photo-editor-danger: #f24c58;\n  --ion-photo-editor-success: #2dd55b;\n}\n
", + "html": "

インストール のあと、エディターの色を上書きします。

\n

デフォルトの色は設定済みですが、上書きできます: https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/pages/core.scss

\n

上書き方法

\n
:root {\n  --ion-photo-editor-background: #2a2a2a;\n  --ion-photo-editor-background-tint: #414141;\n\n  --ion-photo-editor-color: #f0f0f0;\n  --ion-photo-editor-color-tint: #dbdbdb;\n\n  --ion-photo-editor-primary: #4d8dff;\n  --ion-photo-editor-danger: #f24c58;\n  --ion-photo-editor-success: #2dd55b;\n\n  --ion-photo-editor-header-button-color-on-light: #222428;\n  --ion-photo-editor-header-button-color-on-dark: #f4f5f8;\n}\n

ヘッダーボタンのカラースキーム

\n

PhotoEditorPagePhotoViewerPage では、モーダルの componentPropsheaderButtonColorScheme: 'light' | 'dark' を指定する必要があります。ion-toolbar が暗色または黒色の場合は dark、明色または白色の場合は light を選択してください。最終的なツールバーの外観はCSS、半透明効果、実行時のテーマ上書きによって変わるため、ライブラリ側では確実に判定できません。利用側で明示的に選択する必要があります。

\n

@rdlabo/ionic-theme-ios26 v3では、iOS 26テーマとダークモードのスタイルよりあとに、オプションの連携スタイルシートを読み込みます。

\n
@import '@rdlabo/ionic-theme-ios26/dist/css/ionic-theme-ios26.css';\n@import '@ionic/angular/css/palettes/dark.class.css';\n@import '@rdlabo/ionic-theme-ios26/dist/css/ionic-theme-ios26-dark-class.css';\n@import '@rdlabo/ionic-angular-photo-editor/css/ios26-header-button-color-scheme.css';\n

必要に応じて、対応するAlwaysまたはSystemダークモード用のimportへ置き換えてください。photo-editor連携スタイルシートは、局所的なヘッダーの配色がアプリ全体の配色を上書きできるよう、必ず最後に読み込む必要があります。iOS 26テーマを使用しないアプリでは、このオプションのスタイルシートを読み込まないでください。その場合は、通常のIonicボタンの前景色切り替えだけが適用されます。

\n", "headings": [ { "id": "%E4%B8%8A%E6%9B%B8%E3%81%8D%E6%96%B9%E6%B3%95", "text": "上書き方法", "level": 2 + }, + { + "id": "%E3%83%98%E3%83%83%E3%83%80%E3%83%BC%E3%83%9C%E3%82%BF%E3%83%B3%E3%81%AE%E3%82%AB%E3%83%A9%E3%83%BC%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%A0", + "text": "ヘッダーボタンのカラースキーム", + "level": 2 } ], "codes": [], @@ -118,7 +123,7 @@ export const PROJECT = { "file": "editor.md", "section": "ガイド", "path": "/projects/ionic-angular-photo-editor/docs/editor", - "html": "

Ionic モーダルで PhotoEditorPage を表示します。インストール のあとで呼び出します。

\n
import { PhotoEditorPage, IPhotoEditorDismiss } from '@rdlabo/ionic-angular-photo-editor';\n\n(async () => {\n  const modal = await this.modalCtrl.create({\n    component: PhotoEditorPage,\n    componentProps: {\n      requireSquare: false,\n      value: 'https://picsum.photos/200/300',\n      labels: {\n        save: '送信', // change '保存' to '送信'\n      },\n    },\n  });\n  await modal.present();\n  const { data } = await modal.onWillDismiss<IPhotoEditorDismiss>();\n  if (data?.value) {\n    console.log(data.value);\n  }\n})();\n

オプション

\n

requireSquare: boolean

\n

true の場合、最初に画像を正方形に切り抜く必要があります。

\n

value: string

\n

画像の URL または base64 文字列です。

\n

labels: IDictionaryForEditor

\n

設定すると、ラベルが上書きされます。

\n

一覧はこちらです。

\n", + "html": "

Ionic モーダルで PhotoEditorPage を表示します。インストール のあとで呼び出します。

\n
import { PhotoEditorPage, IPhotoEditorDismiss, PhotoEditorProps } from '@rdlabo/ionic-angular-photo-editor';\n\n(async () => {\n  const componentProps = {\n    requireSquare: false,\n    value: 'https://picsum.photos/200/300',\n    headerButtonColorScheme: 'dark',\n    labels: {\n      save: '送信', // change '保存' to '送信'\n    },\n  } satisfies PhotoEditorProps;\n  const modal = await this.modalCtrl.create({\n    component: PhotoEditorPage,\n    componentProps,\n  });\n  await modal.present();\n  const { data } = await modal.onWillDismiss<IPhotoEditorDismiss>();\n  if (data?.value) {\n    console.log(data.value);\n  }\n})();\n

オプション

\n

requireSquare: boolean

\n

true の場合、最初に画像を正方形に切り抜く必要があります。

\n

value: string

\n

画像の URL または base64 文字列です。

\n

labels: IDictionaryForEditor

\n

設定すると、ラベルが上書きされます。

\n

一覧はこちらです。

\n

headerButtonColorScheme: 'light' | 'dark'

\n

必須です。ion-toolbar が暗色または黒色の場合は dark、明色または白色の場合は light を選択してください。ツールバーの外観はCSS、半透明コンテンツ、実行時のテーマ上書きによって変わるため、ライブラリ側では判定できません。

\n", "headings": [ { "id": "%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3", @@ -139,6 +144,11 @@ export const PROJECT = { "id": "labels%3A-idictionaryforeditor", "text": "labels: IDictionaryForEditor", "level": 4 + }, + { + "id": "headerbuttoncolorscheme%3A-'light'-%7C-'dark'", + "text": "headerButtonColorScheme: 'light' | 'dark'", + "level": 4 } ], "codes": [], @@ -152,7 +162,7 @@ export const PROJECT = { "file": "viewer.md", "section": "ガイド", "path": "/projects/ionic-angular-photo-editor/docs/viewer", - "html": "

Ionic モーダルで PhotoViewerPage を表示します。インストール のあとで呼び出します。

\n
import { PhotoViewerPage, IPhotoViewerDismiss } from '@rdlabo/ionic-angular-photo-editor';\n\n(async () => {\n  const modal = await this.modalCtrl.create({\n    component: PhotoViewerPage,\n    componentProps: {\n      imageUrls: ['https://picsum.photos/200/300', 'https://picsum.photos/200/300'],\n      index: 0,\n      isCircle: false,\n    },\n  });\n  await modal.present();\n  const { data } = await modal.onWillDismiss<IPhotoViewerDismiss>();\n  if (data?.delete) {\n    // User delete image\n  }\n})();\n

オプション

\n

imageUrls: string[]

\n

画像の URL または base64 文字列の配列です。

\n

index: number

\n

imageUrls のインデックスです。

\n

isCircle: boolean

\n

設定すると、画像が円形で表示されます。

\n

enableDelete: boolean

\n

true の場合、削除ボタンが表示されます。

\n

enableFooterSafeArea: boolean

\n

true の場合、iOS 向けにフッターのセーフエリアを有効にします。

\n

labels: IDictionaryForViewer

\n

設定すると、ラベルが上書きされます。

\n

一覧はこちらです。

\n", + "html": "

Ionic モーダルで PhotoViewerPage を表示します。インストール のあとで呼び出します。

\n
import { PhotoViewerPage, IPhotoViewerDismiss, PhotoViewerProps } from '@rdlabo/ionic-angular-photo-editor';\n\n(async () => {\n  const componentProps = {\n    imageUrls: ['https://picsum.photos/200/300', 'https://picsum.photos/200/300'],\n    index: 0,\n    isCircle: false,\n    headerButtonColorScheme: 'dark',\n  } satisfies PhotoViewerProps;\n  const modal = await this.modalCtrl.create({\n    component: PhotoViewerPage,\n    componentProps,\n  });\n  await modal.present();\n  const { data } = await modal.onWillDismiss<IPhotoViewerDismiss>();\n  if (data?.delete) {\n    // User delete image\n  }\n})();\n

オプション

\n

imageUrls: string[]

\n

画像の URL または base64 文字列の配列です。

\n

index: number

\n

imageUrls のインデックスです。

\n

isCircle: boolean

\n

設定すると、画像が円形で表示されます。

\n

enableDelete: boolean

\n

true の場合、削除ボタンが表示されます。

\n

enableFooterSafeArea: boolean

\n

true の場合、iOS 向けにフッターのセーフエリアを有効にします。

\n

labels: IDictionaryForViewer

\n

設定すると、ラベルが上書きされます。

\n

一覧はこちらです。

\n

headerButtonColorScheme: 'light' | 'dark'

\n

必須です。ion-toolbar が暗色または黒色の場合は dark、明色または白色の場合は light を選択してください。ツールバーの外観はCSS、半透明コンテンツ、実行時のテーマ上書きによって変わるため、ライブラリ側では判定できません。

\n", "headings": [ { "id": "%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3", @@ -188,6 +198,11 @@ export const PROJECT = { "id": "labels%3A-idictionaryforviewer", "text": "labels: IDictionaryForViewer", "level": 4 + }, + { + "id": "headerbuttoncolorscheme%3A-'light'-%7C-'dark'", + "text": "headerButtonColorScheme: 'light' | 'dark'", + "level": 4 } ], "codes": [], diff --git a/src/ionic-angular-photo-editor/docs/ja/editor.md b/src/ionic-angular-photo-editor/docs/ja/editor.md index d21d719..6c8fc80 100644 --- a/src/ionic-angular-photo-editor/docs/ja/editor.md +++ b/src/ionic-angular-photo-editor/docs/ja/editor.md @@ -7,18 +7,20 @@ scrollActiveLine: [] Ionic モーダルで `PhotoEditorPage` を表示します。[インストール](/docs/readme#インストール) のあとで呼び出します。 ```typescript -import { PhotoEditorPage, IPhotoEditorDismiss } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoEditorPage, IPhotoEditorDismiss, PhotoEditorProps } from '@rdlabo/ionic-angular-photo-editor'; (async () => { + const componentProps = { + requireSquare: false, + value: 'https://picsum.photos/200/300', + headerButtonColorScheme: 'dark', + labels: { + save: '送信', // change '保存' to '送信' + }, + } satisfies PhotoEditorProps; const modal = await this.modalCtrl.create({ component: PhotoEditorPage, - componentProps: { - requireSquare: false, - value: 'https://picsum.photos/200/300', - labels: { - save: '送信', // change '保存' to '送信' - }, - }, + componentProps, }); await modal.present(); const { data } = await modal.onWillDismiss(); @@ -43,3 +45,7 @@ true の場合、最初に画像を正方形に切り抜く必要があります 設定すると、ラベルが上書きされます。 一覧は[こちら](https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/dictionaries.ts)です。 + +#### headerButtonColorScheme: 'light' | 'dark' + +必須です。`ion-toolbar` が暗色または黒色の場合は `dark`、明色または白色の場合は `light` を選択してください。ツールバーの外観はCSS、半透明コンテンツ、実行時のテーマ上書きによって変わるため、ライブラリ側では判定できません。 diff --git a/src/ionic-angular-photo-editor/docs/ja/theme.md b/src/ionic-angular-photo-editor/docs/ja/theme.md index bd65602..4ae473b 100644 --- a/src/ionic-angular-photo-editor/docs/ja/theme.md +++ b/src/ionic-angular-photo-editor/docs/ja/theme.md @@ -21,5 +21,23 @@ scrollActiveLine: [] --ion-photo-editor-primary: #4d8dff; --ion-photo-editor-danger: #f24c58; --ion-photo-editor-success: #2dd55b; + + --ion-photo-editor-header-button-color-on-light: #222428; + --ion-photo-editor-header-button-color-on-dark: #f4f5f8; } ``` + +## ヘッダーボタンのカラースキーム + +`PhotoEditorPage` と `PhotoViewerPage` では、モーダルの `componentProps` に `headerButtonColorScheme: 'light' | 'dark'` を指定する必要があります。`ion-toolbar` が暗色または黒色の場合は `dark`、明色または白色の場合は `light` を選択してください。最終的なツールバーの外観はCSS、半透明効果、実行時のテーマ上書きによって変わるため、ライブラリ側では確実に判定できません。利用側で明示的に選択する必要があります。 + +`@rdlabo/ionic-theme-ios26` v3では、iOS 26テーマとダークモードのスタイルよりあとに、オプションの連携スタイルシートを読み込みます。 + +```scss +@import '@rdlabo/ionic-theme-ios26/dist/css/ionic-theme-ios26.css'; +@import '@ionic/angular/css/palettes/dark.class.css'; +@import '@rdlabo/ionic-theme-ios26/dist/css/ionic-theme-ios26-dark-class.css'; +@import '@rdlabo/ionic-angular-photo-editor/css/ios26-header-button-color-scheme.css'; +``` + +必要に応じて、対応するAlwaysまたはSystemダークモード用のimportへ置き換えてください。photo-editor連携スタイルシートは、局所的なヘッダーの配色がアプリ全体の配色を上書きできるよう、必ず最後に読み込む必要があります。iOS 26テーマを使用しないアプリでは、このオプションのスタイルシートを読み込まないでください。その場合は、通常のIonicボタンの前景色切り替えだけが適用されます。 diff --git a/src/ionic-angular-photo-editor/docs/ja/viewer.md b/src/ionic-angular-photo-editor/docs/ja/viewer.md index bfda018..8c65fab 100644 --- a/src/ionic-angular-photo-editor/docs/ja/viewer.md +++ b/src/ionic-angular-photo-editor/docs/ja/viewer.md @@ -7,16 +7,18 @@ scrollActiveLine: [] Ionic モーダルで `PhotoViewerPage` を表示します。[インストール](/docs/readme#インストール) のあとで呼び出します。 ```typescript -import { PhotoViewerPage, IPhotoViewerDismiss } from '@rdlabo/ionic-angular-photo-editor'; +import { PhotoViewerPage, IPhotoViewerDismiss, PhotoViewerProps } from '@rdlabo/ionic-angular-photo-editor'; (async () => { + const componentProps = { + imageUrls: ['https://picsum.photos/200/300', 'https://picsum.photos/200/300'], + index: 0, + isCircle: false, + headerButtonColorScheme: 'dark', + } satisfies PhotoViewerProps; const modal = await this.modalCtrl.create({ component: PhotoViewerPage, - componentProps: { - imageUrls: ['https://picsum.photos/200/300', 'https://picsum.photos/200/300'], - index: 0, - isCircle: false, - }, + componentProps, }); await modal.present(); const { data } = await modal.onWillDismiss(); @@ -53,3 +55,7 @@ true の場合、iOS 向けにフッターのセーフエリアを有効にし 設定すると、ラベルが上書きされます。 一覧は[こちら](https://github.com/rdlabo-dev/ionic-angular-library/blob/v21.6.2/projects/photo-editor/src/lib/dictionaries.ts)です。 + +#### headerButtonColorScheme: 'light' | 'dark' + +必須です。`ion-toolbar` が暗色または黒色の場合は `dark`、明色または白色の場合は `light` を選択してください。ツールバーの外観はCSS、半透明コンテンツ、実行時のテーマ上書きによって変わるため、ライブラリ側では判定できません。