feat: X 게시글 해시태그 하이라이트 및 클릭 링크 추가
- 해시태그(#xxx): primary 색상 하이라이트, 클릭 시 x.com/hashtag/xxx로 이동 - URL: primary 색상 + 밑줄, 클릭 시 해당 URL로 이동 - 웹(모바일/PC), 앱 모두 적용 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3cf07a8214
commit
1f3c2f3d9b
3 changed files with 104 additions and 30 deletions
|
|
@ -499,13 +499,8 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
// 본문
|
// 본문
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: _buildLinkedText(
|
||||||
decodeHtmlEntities(schedule.content ?? schedule.title),
|
decodeHtmlEntities(schedule.content ?? schedule.title),
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
height: 1.5,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 이미지
|
// 이미지
|
||||||
|
|
@ -691,6 +686,69 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 해시태그, URL을 감지해서 링크로 만드는 RichText
|
||||||
|
Widget _buildLinkedText(String text) {
|
||||||
|
// 해시태그 (#xxx) 또는 URL (https://...) 매칭
|
||||||
|
final pattern = RegExp(r'(#[^\s#]+)|(https?://[^\s]+)');
|
||||||
|
final spans = <InlineSpan>[];
|
||||||
|
int lastEnd = 0;
|
||||||
|
|
||||||
|
for (final match in pattern.allMatches(text)) {
|
||||||
|
// 매치 앞의 일반 텍스트
|
||||||
|
if (match.start > lastEnd) {
|
||||||
|
spans.add(TextSpan(
|
||||||
|
text: text.substring(lastEnd, match.start),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
final matched = match.group(0)!;
|
||||||
|
final isHashtag = matched.startsWith('#');
|
||||||
|
|
||||||
|
final url = isHashtag
|
||||||
|
? 'https://x.com/hashtag/${Uri.encodeComponent(matched.substring(1))}?src=hashtag_click'
|
||||||
|
: matched;
|
||||||
|
|
||||||
|
spans.add(WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _launchUrl(url),
|
||||||
|
child: Text(
|
||||||
|
matched,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppColors.primary,
|
||||||
|
decoration: isHashtag ? TextDecoration.none : TextDecoration.underline,
|
||||||
|
decorationColor: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
lastEnd = match.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 남은 텍스트
|
||||||
|
if (lastEnd < text.length) {
|
||||||
|
spans.add(TextSpan(
|
||||||
|
text: text.substring(lastEnd),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return RichText(text: TextSpan(children: spans));
|
||||||
|
}
|
||||||
|
|
||||||
/// 인증 배지
|
/// 인증 배지
|
||||||
Widget _buildVerifiedBadge() {
|
Widget _buildVerifiedBadge() {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
|
|
|
||||||
|
|
@ -13,26 +13,37 @@ import Birthday from './Birthday';
|
||||||
function linkifyText(text) {
|
function linkifyText(text) {
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
// URL 패턴: http(s)://로 시작하거나 일반적인 단축 URL 도메인
|
// 해시태그 또는 URL 매칭
|
||||||
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
|
const pattern = /(#[^\s#]+)|(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
|
||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
while ((match = urlPattern.exec(text)) !== null) {
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = match[0];
|
const matched = match[0];
|
||||||
const href = url.startsWith('http') ? url : `https://${url}`;
|
|
||||||
|
|
||||||
parts.push(
|
if (matched.startsWith('#')) {
|
||||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
// 해시태그
|
||||||
{url}
|
const tag = matched.slice(1);
|
||||||
</a>
|
parts.push(
|
||||||
);
|
<a key={match.index} href={`https://x.com/hashtag/${encodeURIComponent(tag)}?src=hashtag_click`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
|
{matched}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// URL
|
||||||
|
const href = matched.startsWith('http') ? matched : `https://${matched}`;
|
||||||
|
parts.push(
|
||||||
|
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
|
{matched}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,34 +9,39 @@ import { Lightbox } from '@/components/common';
|
||||||
function linkifyText(text) {
|
function linkifyText(text) {
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
// URL 패턴: http(s)://로 시작하거나 일반적인 도메인 패턴
|
// 해시태그 또는 URL 매칭
|
||||||
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
|
const pattern = /(#[^\s#]+)|(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
|
||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
while ((match = urlPattern.exec(text)) !== null) {
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
// 매치 전 텍스트
|
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL
|
const matched = match[0];
|
||||||
let url = match[0];
|
|
||||||
// http(s)://가 없으면 추가
|
|
||||||
const href = url.startsWith('http') ? url : `https://${url}`;
|
|
||||||
|
|
||||||
parts.push(
|
if (matched.startsWith('#')) {
|
||||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
const tag = matched.slice(1);
|
||||||
{url}
|
parts.push(
|
||||||
</a>
|
<a key={match.index} href={`https://x.com/hashtag/${encodeURIComponent(tag)}?src=hashtag_click`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
);
|
{matched}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const href = matched.startsWith('http') ? matched : `https://${matched}`;
|
||||||
|
parts.push(
|
||||||
|
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
|
{matched}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 나머지 텍스트
|
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(text.slice(lastIndex));
|
parts.push(text.slice(lastIndex));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue